【仓颉语言入门 · 第13课】闭包、作用域与函数类型

第 12 课我们用 Lambda 写了一堆"一次性动作",但有个细节没展开:nums.map { x => x * factor } 里的 factor 不是参数,也不是 Lambda 内部定义的——它是从外层"抓"进来的。这节课把这件事讲透:变量的作用域边界、闭包如何捕获外层变量、letvar 捕获的根本差别、可变状态的正确封装方式,以及函数类型在变量、参数、返回值、容器中的完整规则

本文所有代码与报错文案均在仓颉 SDK 1.1.3 下逐行实测编译运行。途中会澄清三个高频误区:仓颉的 var 不能被存起来的 Lambda 捕获(只能"直接调用")、for 循环变量是按迭代独立绑定的(没有 JS 那种经典闭包陷阱)、Lambda 里的 return 只从 Lambda 本身返回(非局部返回不支持带值)。


目录(系列导航)

整套路线共 7 个模块、30 课

| 模块 | 课次 | 内容 |
| — | — | — — |
| 一、环境与入门 | 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 数据处理实战 |

  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 数据处理程序

一、作用域:变量住在哪里

"作用域"就是一个变量能被访问到的代码范围。仓颉采用词法作用域(静态作用域):变量能在哪用,写代码时就定死了,跟运行时谁调用无关。

1.1 三层作用域

let globalVar = 100          // 全局作用域:整个文件都能用

func demo(n: Int64): Int64 {
    let localVar = n + 1     // 函数作用域:只在 demo 内部
    if (localVar > 0) {
        let blockVar = 42    // 块级作用域:只在这个 if 块里
        println("${globalVar} ${localVar} ${blockVar}")
    }
    // println(blockVar)     // ❌ 编译错误:找不到 blockVar
    return localVar
}

全局作用域的变量任何函数都能读;函数作用域的变量只在函数体内;块级作用域(if / for / while 的 {})变量只在块内。

1.2 变量遮蔽(Shadowing)

内层可以声明与外层同名的变量,此时内层变量"遮住"外层的:

main(): Int64 {
    let x = 10
    println("outer x=${x}")       // 10

    if (x > 0) {
        let x = 20                // 遮蔽外层 x
        println("inner x=${x}")   // 20
    }

    println("after x=${x}")       // 10:外层 x 没变
    return 0
}

📌 遮蔽是"换了个新变量",不是"修改旧变量"。外层的 x 在内层被遮蔽期间完全不受影响,出了内层块还是原来的值。想修改外层变量,老老实实重新赋值(前提是它是 var)。

🚫 仓颉没有独立的"裸代码块"语法。{ let y = 100 } 会被编译器当成 Lambda({ => ... } 的简写),不是作用域块。想创建临时作用域,用 if (条件) { ... }for / while 的块。do { ... } 只用于 do-while 循环。


二、闭包:函数 + 它记住的环境

闭包 = 函数 + 该函数定义时所处的词法环境。通俗说,函数会"记住"它被创建时能看到的那些变量,即使创建它的外层函数已经执行完毕、那些变量按理说该"消失"了。

2.1 捕获 let(不可变变量)

这是最常见、最安全的闭包形式:

func makeAdder(base: Int64): (Int64) -> Int64 {
    return { x => x + base }   // base 是参数(let 语义),被 Lambda 捕获
}

main(): Int64 {
    let add5 = makeAdder(5)
    println("${add5(10)}")    // 15
    println("${add5(20)}")    // 25
    return 0
}

makeAdder(5) 执行完后,参数 base 按理说该销毁了,但返回的 Lambda 还"记着"它,所以每次调用都能加上 5。这就是闭包的核心能力。

2.2 捕获 var(可变变量):有限制

let 是不可变的,捕获了也改不了,所以闭包拿着很安全。但 var 是可变的,闭包如果能随意修改外层 var,就会引入难以追踪的副作用。仓颉对此的态度是:存起来的 Lambda(赋值给变量、作为返回值、传给别的函数)一律不能捕获 var,只有**“当场直接调用”**的 Lambda 可以。

func makeCounter(): () -> Int64 {
    var n = 0
    return { => n += 1; n }   // ❌ n 是 var,Lambda 被返回了
}
error: lambda capturing mutable variables needs to be called directly
note: 'n' is mutable

"直接调用"就是 Lambda 写完立刻加 () 执行,不存、不传:

main(): Int64 {
    var n = 0
    { => n += 1 }()    // ✅ 当场调用,允许
    println("${n}")    // 1
    return 0
}

⚠️ nums.forEach { x => sum += x } 也不行——虽然 forEach 会调用这个 Lambda,但它属于"传参",编译器判定为非直接调用,照样报 lambda capturing mutable variables needs to be called directly。要在遍历中累加,用 fold(第 12 课)或退回到 for 循环。


三、可变状态怎么办:容器模式

闭包需要"记住一个会变的值"怎么办?核心思路是:不要捕获 var 本身,而是捕获一个指向可变容器的 let。容器本身不可变(let 引用),但容器里装的内容可变。

3.1 数组当容器(最简单)

func makeCounter(): () -> Int64 {
    let cell = [0]              // cell 是 let,但 cell[0] 可改
    return { =>
        cell[0] = cell[0] + 1
        cell[0]
    }
}

main(): Int64 {
    let c = makeCounter()
    println("${c()} ${c()} ${c()}")   // 1 2 3
    return 0
}

celllet,指向同一个数组;Lambda 通过 cell[0] 修改数组元素,绕过了"不能捕获 var"的限制。适合单个数值。

3.2 class 当容器(推荐,语义清晰)

class CounterCell {
    var value: Int64 = 0
    public func get(): Int64 { return value }
    public func set(v: Int64) { value = v }
}

func makeCounter(): () -> Int64 {
    let cell = CounterCell()
    return { =>
        cell.set(cell.get() + 1)
        cell.get()
    }
}

class 的实例是引用类型,cellletcell.value 可变。这是工程里最清晰的写法。

📌 var 字段的修改方法必须能修改实例状态——在 class 里普通 func 即可修改 var 字段(class 默认是引用语义)。struct 是值类型,修改 var 字段的方法需要特殊处理,优先用 class 做可变容器

3.3 HashMap 当容器(记忆化 / 缓存)

import std.collection.*

func makeMemoized(): (Int64) -> Int64 {
    let cache = HashMap<Int64, Int64>()
    return { n =>
        if (cache.contains(n)) {
            return cache.get(n) ?? 0
        }
        let result = n * n
        cache.add(n, result)     // 注意:HashMap 用 add,不是 put/set
        result
    }
}

cachelet,但 HashMap 内部可增删。适合键值对形式的可变状态,做记忆化缓存(memoization)的经典套路。

3.4 多个闭包共享状态

用同一个容器,可以让多个闭包读写同一份状态:

class AccountCell {
    var balance: Int64 = 0
    public func get(): Int64 { return balance }
    public func deposit(v: Int64) { balance += v }
    public func withdraw(v: Int64): Bool {
        if (balance >= v) { balance -= v; return true }
        return false
    }
}

func createAccount(initial: Int64): ((Int64) -> Unit, (Int64) -> Bool, () -> Int64) {
    let cell = AccountCell()
    cell.deposit(initial)
    return (
        { v => cell.deposit(v) },
        { v => cell.withdraw(v) },
        { => cell.get() }
    )
}

三个闭包(存款、取款、查余额)共享同一个 AccountCell,实现了"对象"的效果,却没有用 class 的方法,全靠闭包 + 容器。


四、函数类型完全指南

第 11、12 课零散见过函数类型,这里系统梳理。函数类型的写法是:

(参数1类型, 参数2类型, ...) -> 返回类型

无参函数:() -> 返回类型

4.1 作为变量类型

func add(a: Int64, b: Int64): Int64 { return a + b }
func sub(a: Int64, b: Int64): Int64 { return a - b }

main(): Int64 {
    let op: (Int64, Int64) -> Int64 = add
    println("${op(3, 4)}")      // 7

    var current = add
    current = sub                // 同类型的函数可以互相赋值
    println("${current(3, 4)}")  // -1
    return 0
}

4.2 作为参数 / 返回值

// 参数是函数
func apply(op: (Int64, Int64) -> Int64, a: Int64, b: Int64): Int64 {
    return op(a, b)
}

// 返回值是函数
func pick(op: String): (Int64, Int64) -> Int64 {
    if (op == "mul") { return { a, b => a * b } }
    return { a, b => a + b }
}

4.3 作为数组 / 集合元素

函数类型跟 Int64String 一样,能放进容器:

let ops: Array<(Int64, Int64) -> Int64> = [
    { a, b => a + b },
    { a, b => a - b },
    { a, b => a * b }
]
for (op in ops) {
    print("${op(8, 3)} ")    // 11 5 24
}

4.4 类型别名:type

函数类型写长了啰嗦,用 type 起别名:

type OpType = (Int64, Int64) -> Int64

func apply(op: OpType, a: Int64, b: Int64): Int64 {
    return op(a, b)
}

🚫 仓颉用 type,不是 typealias。写 typealias OpType = ...expected declaration, found 'typealias'

4.5 函数类型的 Option 包装

函数也可以"可空",用 Option<函数类型> 包装:

let handler: Option<(Int64) -> Int64> = None
println("${handler.isNone()}")          // true

let h2: Option<(Int64) -> Int64> = Some({ x => x * 2 })
let fn = h2 ?? { x => 0 }
println("${fn(5)}")                     // 10

注意可空函数类型的写法是 Option<(Int64) -> Int64>,不要写成 ((Int64) -> Int64)?——后者会报语法错误。


五、循环变量捕获:仓颉没有经典陷阱

JavaScript 里有个经典坑:for (var i = 0; i < 3; i++) 中创建的闭包会共享同一个 i,最终都是 3。仓颉没有这个问题——for 循环的变量每次迭代都是独立绑定

import std.collection.*

main(): Int64 {
    let factories = ArrayList<() -> Int64>()
    for (i in 1..=3) {
        factories.add({ => i * 10 })
    }
    for (f in factories) {
        print("${f()} ")     // 10 20 30:各自记住当时的 i
    }
    return 0
}

输出 10 20 30,三个闭包各记各的。这是因为仓颉的 ilet 语义,每轮循环一个新绑定。


六、嵌套函数

函数可以定义在另一个函数内部,内层函数能直接访问外层的局部变量(这本身就是闭包):

func outer(n: Int64): Int64 {
    func inner(): Int64 {
        return n * 2          // 直接用外层的 n
    }
    return inner()
}

main(): Int64 {
    println("${outer(5)}")    // 10
    return 0
}

嵌套函数只在外层函数体内可见,外层执行完它就消失。适合把"只在一个地方用的小逻辑"就近定义,不用提升到全局。


七、return 语义总结

第 12 课讲了 forEach 里的 return,这里把所有情况归拢:

场景return 的效果
普通函数里从该函数返回
for / while 循环里从外层函数返回(带值)
Lambda 里、无值 return只结束当次 Lambda 调用(类似 continue)
Lambda 里、带值 return❌ 编译报错

Lambda 带值 return 的报错:

func findTarget(): Int64 {
    let nums = [1, 2, 3]
    nums.forEach { x =>
        if (x == 2) {
            return x        // ❌
        }
    }
    return 0
}
error: the return type of this function cannot be calculated from the function body
note: The types 'Int64' and 'Unit' do not have the smallest common supertype

📌 想在遍历中"找到就返回",用 for 循环配合 return;Lambda 适合"对每个元素做点事",不适合"提前终止整个流程"。这是仓颉的设计取舍:Lambda 的 return 永远是局部的,不会从外层函数"非局部返回"。


八、CIDE 实操:可配置计算器工厂

8.1 编写程序

综合运用:闭包捕获不可变配置、闭包通过 class 容器共享可变状态、函数类型作为参数和返回值、循环变量捕获、函数类型数组。

import std.collection.*

// 可变状态容器:操作计数
class OpCounter {
    var count: Int64 = 0
    public func get(): Int64 { return count }
    public func inc() { count += 1 }
}

// 工厂:包装两个运算,自动计数
func makeCalculator(
    name: String,
    add: (Int64, Int64) -> Int64,
    mul: (Int64, Int64) -> Int64
): (String, (Int64, Int64) -> Int64, (Int64, Int64) -> Int64, OpCounter) {
    let counter = OpCounter()
    let safeAdd: (Int64, Int64) -> Int64 = { a, b =>
        counter.inc()
        add(a, b)
    }
    let safeMul: (Int64, Int64) -> Int64 = { a, b =>
        counter.inc()
        mul(a, b)
    }
    return (name, safeAdd, safeMul, counter)
}

func makeCounter(): () -> Int64 {
    let cell = [0]
    return { => cell[0] = cell[0] + 1; cell[0] }
}

main(): Int64 {
    // ① 配置一个普通计算器
    let (name, add, mul, counter) = makeCalculator(
        "普通",
        { a, b => a + b },
        { a, b => a * b }
    )

    println("=== ${name} 计算器 ===")
    println("3 + 4 = ${add(3, 4)}")
    println("5 * 6 = ${mul(5, 6)}")
    println("操作次数:${counter.get()}")

    // ② 数组容器的计数器
    let c = makeCounter()
    println("数组计数器:${c()} ${c()} ${c()}")

    // ③ 循环变量捕获(每次迭代独立绑定)
    let factories = ArrayList<() -> Int64>()
    for (i in 1..=3) {
        factories.add({ => i * 10 })
    }
    print("循环捕获:")
    for (f in factories) {
        print("${f()} ")
    }
    println()

    // ④ 函数类型数组
    let ops: Array<(Int64, Int64) -> Int64> = [
        { a, b => a + b },
        { a, b => a - b },
        { a, b => a * b }
    ]
    print("3 种运算 (8,3):")
    for (op in ops) {
        print("${op(8, 3)} ")
    }
    println()

    return 0
}

8.2 运行与验证

=== 普通 计算器 ===
3 + 4 = 7
5 * 6 = 30
操作次数:2
数组计数器:1 2 3
循环捕获:10 20 30
3 种运算 (8,3):11 5 24

关键点解读:

  • makeCalculator 返回四个值(元组):名称、包装后的加法、包装后的乘法、共享的计数器。两个包装闭包都捕获同一个 counter,所以调用 addmul 都会让计数 +1。
  • makeCounter 用数组 [0] 当容器,cell[0] 可改,cell 本身是 let
  • 循环捕获输出 10 20 30,证明每次迭代的 i 是独立绑定。
  • 函数数组 ops 存放三个不同的 Lambda,遍历调用得到 11 5 24

可以自己动手改:

  • makeCalculator 再传入一个 sub 运算,包装后返回;
  • 把数组容器换成 CounterCell class,体会两种写法的差异;
  • 在 ④ 的数组里加一个 { a, b => a / b },注意整数除法。

8.3 用调试器观察闭包状态

  1. safeAddcounter.inc() 行打断点,Shift + F9 启动调试;
  2. 调用 add(3, 4) 时断住,观察 Variables 面板里的 counter——它是从外层 makeCalculator 捕获的,此时 count 从 0 变成 1;
  3. 再调用 mul(5, 6),同一个 countercount 从 1 变成 2,证明两个闭包共享状态;
  4. makeCountercell[0] = cell[0] + 1 行打断点,观察 cell 数组的变化。

调试结束点停止按钮。


九、常见问题 FAQ

Q1:闭包和普通函数到底什么区别?
普通函数只依赖传入的参数;闭包除了参数,还"记住"了定义时外层环境的变量。实现上,闭包 = 函数代码 + 一份捕获的变量引用。

Q2:为什么捕获 var 会报错?捕获 let 就没事?
let 不可变,闭包拿着只读引用,不会引发副作用,编译器放心。var 可变,如果允许存起来的闭包随意修改,就可能出现"函数 A 改了函数 B 的局部变量"这种难以追踪的情况。仓颉只允许"直接调用"的 Lambda 捕获 var(当场执行完就销毁,不会留下隐患)。

Q3:想让闭包持有可变状态,有哪些写法?
三种容器模式:① 单元素数组 let cell = [0],改 cell[0];② class 实例,let cell = MyClass(),改 cell.varField;③ 集合类 HashMap / ArrayList,改内部元素。核心都是:捕获一个 let 引用,通过引用修改其内部状态。

Q4:仓颉有非局部返回(non-local return)吗?
没有。Lambda 里的 return 只从 Lambda 本身返回,永远不会跳出外层函数。带值 return 还会直接编译报错。想从遍历中提前返回,改用 for 循环。

Q5:函数类型可以做 struct 的字段吗?
可以,但 let 字段必须有默认值或在构造函数中初始化。如果字段是函数类型且不想给默认值,建议用 class 或直接用元组返回多个闭包(见第八节的 createAccount 模式)。

Q6:函数类型怎么起别名?
type 别名 = 函数类型,例如 type Op = (Int64, Int64) -> Int64。不要写 typealias(那是别的语言的关键字)。

Q7:闭包会内存泄漏吗?
闭包持有的变量只要闭包还活着就不会被回收。如果一个长生命周期的闭包捕获了大对象,该对象就无法释放。实际开发中注意不要让全局闭包捕获不必要的大对象即可,日常使用无需担心。

Q8:多个闭包能共享同一个容器吗?
能。只要多个闭包捕获的是同一个 let 引用(同一个 class 实例 / 同一个数组 / 同一个 HashMap),它们就共享同一份状态。这是用闭包实现"对象"和"封装"的基础。

Q9:for 循环里创建闭包,会有变量共享问题吗?
不会。仓颉的 for 循环变量是 let 语义,每次迭代独立绑定,闭包各自记住当时的值。没有 JavaScript var 那种经典陷阱。

Q10:闭包里能调用递归吗?
Lambda 不能直接递归(它没有名字,无法引用自己)。需要递归的逻辑,定义命名函数。命名函数的递归与闭包无关,正常写即可。


十、课后练习

  1. 用数组容器写一个 makeAccumulator():返回一个 (Int64) -> Int64 闭包,每次调用把参数累加到内部状态并返回累加值。例如 acc(5) 返回 5,再 acc(3) 返回 8。
  2. class 容器重写第 1 题,体会两种容器的代码风格差异。
  3. 写一个 makeLogger(prefix: String): (String) -> Unit,返回的闭包打印 [prefix] 消息。验证 makeLogger("INFO")("hello") 输出 [INFO] hello
  4. makeMultiplier(factor: Int64): (Int64) -> Int64,然后用 Array 存三个不同 factor 的乘法器,遍历对同一个数调用并打印结果。
  5. 综合:用 HashMap<String, Int64> 做容器,写一个 makeVoteCounter(): (String) -> Unit,每次调用给对应候选人 +1 票;再写一个 getVotes(): HashMap<String, Int64> 返回当前票数。在 main 中投几票后打印结果。提示:两个闭包共享同一个 HashMap。

下节预告

这三节课(11~13)把函数从"命名定义"到"匿名 Lambda"再到"闭包捕获"讲完了。但有个问题一直没解决:想"逐个产出"一系列值(比如斐波那契数列、大文件的逐行读取),难道要一次性全算出来塞进数组?第 14 课 迭代器 Iterator 与 Sequence 就来解决这个问题——用 next() 按需产出、惰性求值,配合闭包实现无限序列。我们下节课见!


系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.1.3)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入(例如新版本放宽了 var 捕获限制、补充了非局部返回等),以你本地版本为准,欢迎评论区交流。


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

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

更多推荐