【仓颉语言入门 · 第19课】泛型编程:一份逻辑,任意类型复用

第 18 课的 MyOption<T>、MyResult<T, E> 已经提前用了一个新玩意儿:定义类型时不写死它装什么,而是留一个占位符 T,使用时再决定。本课就把它讲透——泛型(generics):把"类型"也变成参数,让同一份函数/结构体/类的逻辑,能对任意类型复用。第 8 课天天用的 ArrayList<T>、第 14 课的 Iterator<T>,背后都是它。

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


目录(系列导航)

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

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

一、为什么需要泛型:从"复制粘贴"说起

假设要写一个"原样返回参数"的函数。为了兼容整数和字符串,没有泛型时你只能复制粘贴两份:

func echoInt(value: Int64): Int64 {
    return value
}

func echoString(value: String): String {
    return value
}

逻辑一模一样,只有参数类型不同。要是再想支持 Float64、Bool、UInt8……就得继续复制第三份、第四份。第 8 课的 ArrayList 也面临同样的问题:总不能为每种元素都写一个 IntArrayList、StringArrayList。

泛型的思路是:函数里的类型先不写死,用一个占位符代替;调用时再告诉它"这次用什么类型"。占位符通常写作 T(Type 的首字母),你也可以用任何合法标识符。


二、泛型函数:把类型也变成"参数"

2.1 第一个泛型函数

在函数名后面加一对尖括号 <T>,就声明了一个类型参数:

func echo<T>(value: T): T {
    return value
}

main(): Int64 {
    println(echo(42))        // T 被推断为 Int64
    println(echo("你好"))    // T 被推断为 String
    println(echo(true))      // T 被推断为 Bool
    return 0
}

运行结果:

42
你好
true

一个函数,三种类型,逻辑只写了一遍。

2.2 类型实参通常自动推断

调用 echo(42) 时,编译器从实参 42 推断出 T = Int64,不需要你手写。绝大多数场景靠推断就够了。

2.3 也可以显式指定类型实参

推断不出来或想写得更明确时,可以在函数名后用 <类型> 显式给出:

main(): Int64 {
    println(echo<Int64>(42))
    println(echo<String>("显式类型实参"))
    return 0
}

运行结果:

42
显式类型实参

2.4 多个类型参数:<K, V>

类型参数可以有多个,用逗号分隔。下面三个例子分别演示:取数组末元素、组装键值对、交换数组中两个位置:

func getLast<T>(values: Array<T>): T {
    return values[values.size - 1]
}

// K、V 是两个互相独立的类型参数
func makePair<K, V>(key: K, value: V): (K, V) {
    return (key, value)
}

func swapAt<T>(values: Array<T>, i: Int64, j: Int64): Unit {
    let tmp = values[i]
    values[i] = values[j]
    values[j] = tmp
}

main(): Int64 {
    println(getLast([1, 2, 3, 4, 5]))
    println(getLast(["Cangjie", "Java", "Python"]))

    let p = makePair("年龄", 18)
    println("${p[0]}=${p[1]}")

    let nums = [10, 20, 30]
    swapAt(nums, 0, 2)
    println("${nums[0]}, ${nums[1]}, ${nums[2]}")
    return 0
}

运行结果:

5
Python
年龄=18
30, 20, 10

注意 getLast 里 Array<T> 的元素类型、下标返回值、函数返回值三处的 T 是同一个——这正是泛型能保证"类型对得上"的原因:传入 Array<Int64>,返回的就一定是 Int64,不会把整数当字符串用。


三、无约束的 T:能做什么,不能做什么

T 只是个占位符,在加任何约束之前,编译器对它"长什么样"一无所知。

3.1 能做的事

不依赖类型内部结构的操作都可以,前面的例子已经证明:整体赋值、作为参数传递、作为返回值、放进同类型的容器(Array<T>、ArrayList<T>)。

3.2 不能做的事(一):对两个 T 做运算

直接写 a + b:

func addTwice<T>(a: T, b: T): T {
    return a + b
}

编译报错:

error: invalid binary operator '+' on type 'Generics-T' and 'Generics-T'
  # note: you may want to implement 'operator func +(right: Generics-T)' for type 'Generics-T'

编译器的意思很直白:我不知道 T 有没有实现 +,你要不用不了。报错里的 Generics-T 就是"泛型参数 T"在编译器内部的叫法。

3.3 不能做的事(二):直接打印 T

func printIt<T>(value: T): Unit {
    println(value)
}

编译报错:

error: mismatched types
  expected 'Struct-String', found 'Generics-T'

因为 println 需要的是 String,而无约束的 T 不保证能转成字符串。

想让上面两段代码合法,就得给 T 加约束——下一节。


四、泛型约束:where

约束用关键字 where 写在参数列表后面,形式为 where T <: 接口,读作"T 必须实现这个接口"。有了约束,函数体里就能用该接口提供的能力。

4.1 约束 T <: ToString:可以打印了

func printIt<T>(value: T): Unit where T <: ToString {
    println("值是:${value}")
}

main(): Int64 {
    printIt(42)
    printIt("仓颉")
    return 0
}

运行结果:

值是:42
值是:仓颉

4.2 约束 T <: Comparable:可以比大小了

第 18 课讲过,实现 Comparable<T> 就自动拥有 <、>、== 等比较运算。泛型求最大值:

func maxOf<T>(a: T, b: T): T where T <: Comparable<T> {
    if (a > b) {
        return a
    }
    return b
}

main(): Int64 {
    println(maxOf(3, 9))
    println(maxOf(2.5, 1.8))
    println(maxOf("apple", "banana"))
    return 0
}

运行结果:

9
2.500000
banana

整数、浮点数、字符串都能比较,一份函数全搞定。

4.3 多个约束:用 & 合并

一个类型参数可以同时要求多个接口,用 & 连接。下例要求 T 既能比较、又能打印:

func reportMax<T>(a: T, b: T): Unit
where T <: Comparable<T> & ToString {
    let m = maxOf(a, b)
    println("较大的是:${m}")
}

main(): Int64 {
    reportMax(10, 20)
    reportMax("cat", "dog")
    return 0
}

运行结果:

较大的是:20
较大的是:dog

4.4 约束不满足时:编译期直接拦下

拿一个没有实现 Comparable<T> 的普通类,硬塞进要求该约束的泛型类:

class ComparableBox<T> where T <: Comparable<T> {
    init() {}
}

class PlainPoint {
    let x: Int64
    let y: Int64

    init(x: Int64, y: Int64) {
        this.x = x
        this.y = y
    }
}

main(): Int64 {
    let box = ComparableBox<PlainPoint>()
    return 0
}

编译报错:

error: generics type arguments do not match the constraint of 'Class-ComparableBox<Generics-T>'
note: 'Class-PlainPoint' is not a subtype of 'Interface-Comparable<Class-PlainPoint>'

错误在编译期就暴露,而不是等运行到比较那一步才崩溃——这就是约束的价值。

4.5 常用约束速查

约束接口保证你能做什么备注
ToStringtoString(),可 println、可字符串插值非泛型接口
Equatable<T>==(!= 自动获得)泛型接口
Comparable<T>< > == <= >=,compare 返回 Ordering见第 18 课
Hashable可做 HashMap 的键、HashSet 的元素1.2.0 里是非泛型接口,写 Hashable<T> 会报错,见 FAQ Q4

五、泛型结构体:Box

结构体(第 15 课)同样可以带类型参数:

struct Box<T> {
    let value: T

    init(value: T) {
        this.value = value
    }

    func peek(): T {
        return this.value
    }
}

main(): Int64 {
    let b1 = Box<Int64>(42)
    let b2 = Box<String>("你好")
    println("${b1.peek()}, ${b2.peek()}")
    return 0
}

运行结果:

42, 你好

⚠️ 注意:和某些语言不同,仓颉的 struct 不会因为你写了字段就自动生成构造函数。如果删掉上面的 init,编译会报:

error: the uninitialized member variable 'value' is not initialized in the constructor of class or struct

所以泛型结构体也要像第 16 课讲的那样显式写 init。


六、泛型接口与泛型类:写一个 Stack

6.1 泛型接口

接口名后也能加类型参数。先定义"能存取元素"的容器接口:

interface Container<T> {
    func add(item: T): Unit
    func take(): Option<T>
    func size(): Int64
}

6.2 泛型类实现泛型接口

下面用第 8 课的 ArrayList<T> 实现一个后进先出的栈(记得 import std.collection.*):

class Stack<T> <: Container<T> {
    private let items: ArrayList<T> = ArrayList<T>()

    public init() {}

    public func add(item: T): Unit {
        this.items.add(item)
    }

    public func take(): Option<T> {
        if (this.items.isEmpty()) {
            return None
        }
        let last = this.items.size - 1
        let v = this.items[last]
        this.items.remove(last..last + 1)
        return Some(v)
    }

    public func size(): Int64 {
        return this.items.size
    }
}

main(): Int64 {
    let s = Stack<String>()
    s.add("第一个")
    s.add("第二个")
    s.add("第三个")
    println("栈大小:${s.size()}")
    while (let Some(v) <- s.take()) {
        println("弹出:${v}")
    }
    println("空栈再弹:${s.take()}")
    return 0
}

运行结果:

栈大小:3
弹出:第三个
弹出:第二个
弹出:第一个
空栈再弹:None

两个要点:

  • class Stack<T> <: Container<T>:实现接口时,把接口的类型参数也填成同一个 T。
  • 弹出元素用的是第 8 课的"删单个"写法 remove(last..last + 1)(左闭右开),栈空时 take() 返回 None 而不是抛异常。配合第 18 课的 while-let,取空自然结束。

七、带约束的泛型类与泛型继承

7.1 泛型类也能加约束

类的类型参数同样用 where 约束。这个"有序盒子"要求元素可比较,于是类内可以求最大元素:

open class OrderedBox<T> where T <: Comparable<T> {
    protected let items: ArrayList<T> = ArrayList<T>()

    public init() {}

    public func add(item: T): Unit {
        this.items.add(item)
    }

    public func largest(): Option<T> {
        if (this.items.isEmpty()) {
            return None
        }
        var best = this.items[0]
        for (x in this.items) {
            if (x > best) {
                best = x
            }
        }
        return Some(best)
    }
}

注意类被标成了 open(第 15 课),因为下面要继承它。

7.2 子类写死类型实参

继承时可以把父类的类型参数固定成具体类型,这样子类只服务这一种元素,还能按该类型加专用方法:

class NumberBox <: OrderedBox<Int64> {
    public init() {
        super()
    }

    func sum(): Int64 {
        var total = 0
        for (x in this.items) {
            total += x
        }
        return total
    }
}

main(): Int64 {
    let nb = NumberBox()
    nb.add(3)
    nb.add(9)
    nb.add(5)
    println("总和:${nb.sum()}")
    println("最大:${nb.largest()}")
    return 0
}

运行结果:

总和:17
最大:Some(9)

7.3 子类保留类型参数

子类也可以继续保留泛型,此时父类的约束要原样重写一遍:

class CountingBox<T> <: OrderedBox<T> where T <: Comparable<T> {
    public init() {
        super()
    }

    func count(): Int64 {
        return this.items.size
    }
}

main(): Int64 {
    let cb = CountingBox<String>()
    cb.add("apple")
    cb.add("banana")
    cb.add("cherry")
    println("个数:${cb.count()}")
    println("最大:${cb.largest()}")
    return 0
}

运行结果:

个数:3
最大:Some(cherry)

八、实战:泛型仓库 Repository<K, V>

把本课知识点串起来:一个通用的数据仓库,用 HashMap 存储,键、值的类型都由使用方决定,查询统一返回 Option。

package cjprobe19

import std.collection.*

// K=键、V=值;键必须可哈希、可判等,才能做 HashMap 的键
class Repository<K, V> where K <: Hashable & Equatable<K> {
    private let store: HashMap<K, V> = HashMap<K, V>()

    public init() {}

    func save(key: K, value: V): Unit {
        this.store.add(key, value)
    }

    // 用 get 安全读取:键不存在返回 None,绝不抛异常
    func find(key: K): Option<V> {
        return this.store.get(key)
    }

    func delete(key: K): Bool {
        match (this.store.remove(key)) {
            case Some(_) => true
            case None => false
        }
    }

    func count(): Int64 {
        return this.store.size
    }
}

// 泛型函数:找不到时返回调用方给的默认值
func findOrElse<K, V>(repo: Repository<K, V>, key: K, def: V): V
where K <: Hashable & Equatable<K> {
    match (repo.find(key)) {
        case Some(v) => v
        case None => def
    }
}

main(): Int64 {
    // 用户仓库:编号 -> 姓名
    let users = Repository<Int64, String>()
    users.save(1, "张三")
    users.save(2, "李四")
    println("用户数:${users.count()}")
    println("找1号:${users.find(1)}")
    println("找9号:${findOrElse(users, 9, "未知用户")}")
    println("删1号:${users.delete(1)},再删1号:${users.delete(1)}")

    // 库存仓库:商品名 -> 数量
    let stock = Repository<String, Int64>()
    stock.save("键盘", 50)
    stock.save("鼠标", 0)
    println("键盘库存:${stock.find("键盘")}")
    println("耳机库存:${findOrElse(stock, "耳机", -1)}")
    return 0
}

运行结果:

用户数:2
找1号:Some(张三)
找9号:未知用户
删1号:true,再删1号:false
键盘库存:Some(50)
耳机库存:-1

值得体会的设计:

  • 同一套 Repository 代码,实例化成了两个完全不同类型的仓库——Repository<Int64, String> 和 Repository<String, Int64>,键值类型刚好对调,却一行新逻辑都不用写。
  • 查询走第 8 课强调过的 get(返回 Option),缺键不抛异常;findOrElse 用 match 处理两种结果,调用方拿到的永远是一个确定的值。

九、常见问题 FAQ

Q1:泛型和第 17 课的接口多态,什么时候用哪个?
泛型在编译期就确定了具体类型,编译器为每个类型实参生成对应代码,保留"装的是什么类型"这一信息(Stack<Int64> 里一定是整数);接口多态在运行期分派,适合把一批不同实现混装、统一处理(Array<Drawable> 里放各种图形)。经验法则:写"对类型不挑剔、但要保留类型"的容器和算法用泛型;写"一组可互换的实现"用接口。两者经常配合,比如本课的 Stack<T> <: Container<T>。

Q2:为什么我的泛型函数里不能直接 println(value)、不能 a + b?
因为无约束的 T 对编译器来说是"未知类型",不保证有这些能力。按需要加约束:打印用 where T <: ToString,比大小用 where T <: Comparable<T>。

Q3:用泛型会有性能开销吗?
泛型在编译期按类型实参生成具体代码,调用没有运行期分派的额外开销。真正可能带来装箱开销的是"用接口类型接收值",那是接口多态的代价,不是泛型的。

Q4:为什么键约束写 Hashable<T> 编译报错?
实测在 SDK 1.2.0 中,Hashable 是非泛型接口,直接写 K <: Hashable 即可;写 Hashable<K> 会报 type argument's number does not match type parameter's number。而 Equatable<T>、Comparable<T> 是泛型接口,必须带类型参数。

Q5:一个类型参数最多能加几个约束?
没有"只能一个"的限制,需要几个能力就用 & 连几个,如 T <: Comparable<T> & ToString。约束越多,函数体内能安全使用的操作越多,但能传进来的类型也越少——按需添加即可。

Q6:泛型类可以被继承吗?
可以,但父类要先标 open。两种形式本课都实测过:子类把类型实参写死(NumberBox <: OrderedBox<Int64>),或子类继续保留泛型并把约束原样重写(CountingBox<T> <: OrderedBox<T> where T <: Comparable<T>)。

Q7:标准库的 Option<T> 和第 18 课手写的 MyOption<T> 是什么关系?
它们是同一种泛型枚举形态:enum Option<T> { Some(T) | None }。你完全可以把本课的泛型知识当作"看懂标准库类型签名"的钥匙——以后见到 ArrayList<T>、Iterator<T>、HashMap<K, V>,尖括号里的字母意思都一样:使用时才确定的类型占位符。


十、课后练习

  1. 写泛型函数 first<T>(values: Array<T>): Option<T>(空数组返回 None);再写 contains<T>(values: Array<T>, target: T): Bool,想想它需要 T 满足什么约束。
  2. 写泛型函数 reverse<T>(values: Array<T>): Unit,原地把数组反转(利用本课的 swapAt 思路);用 [1, 2, 3, 4, 5] 和字符串数组分别测试。
  3. 给第六节的 Stack<T> 增加两个方法:peek(): Option<T>(只看栈顶不弹出)和 contains(item: T): Bool(注意给 T 加必要约束)。
  4. 定义泛型结构体 Pair<A, B>(两个字段 first、second),实现 ToString 让它能直接 println——先想清楚约束该怎么写,输出形如 (键, 值)。
  5. 扩展第八节的 Repository<K, V>:增加 update(key: K, value: V): Bool(只有键已存在时才更新,不存在返回 false)和 keys(): ArrayList<K>;各写一个 Repository<Int64, String> 和 Repository<String, Int64> 验证全部方法。

下节预告

泛型让你能"复用逻辑",但有时候你只是想给一个已经存在、又不能改源码的类型(比如标准库的 String)补几个顺手的方法——既不想继承,也不想写一堆工具函数。第 20 课 扩展、类型别名与可见性控制将讲解仓颉的扩展机制(不加子类也能给类型加方法)、typealias 类型别名,以及 public/private 可见性控制,把面向对象这一模块收尾。


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


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

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

更多推荐