【仓颉语言入门 · 第19课】
【仓颉语言入门 · 第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~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 数据处理程序
一、为什么需要泛型:从"复制粘贴"说起
假设要写一个"原样返回参数"的函数。为了兼容整数和字符串,没有泛型时你只能复制粘贴两份:
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 常用约束速查
| 约束接口 | 保证你能做什么 | 备注 |
|---|---|---|
ToString | toString(),可 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>,尖括号里的字母意思都一样:使用时才确定的类型占位符。
十、课后练习
- 写泛型函数
first<T>(values: Array<T>): Option<T>(空数组返回None);再写contains<T>(values: Array<T>, target: T): Bool,想想它需要T满足什么约束。 - 写泛型函数
reverse<T>(values: Array<T>): Unit,原地把数组反转(利用本课的swapAt思路);用[1, 2, 3, 4, 5]和字符串数组分别测试。 - 给第六节的
Stack<T>增加两个方法:peek(): Option<T>(只看栈顶不弹出)和contains(item: T): Bool(注意给T加必要约束)。 - 定义泛型结构体
Pair<A, B>(两个字段first、second),实现ToString让它能直接println——先想清楚约束该怎么写,输出形如(键, 值)。 - 扩展第八节的
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
更多推荐


所有评论(0)