【仓颉语言入门 · 第17课】
【仓颉语言入门 · 第17课】接口 interface 与实现
第 15、16 课把 struct/class 的骨架和血肉都搭好了。但还差最后一块拼图:类与类之间怎么约定"能力"?怎么让一只鸟和一架飞机共享"能飞"这个抽象?怎么写一个函数,对所有"能发消息的东西"统一处理?答案就是接口(interface)——本课讲清仓颉的接口定义、实现、继承与多态,顺便兑现第 16 课埋下的伏笔:
protected和类的继承。本文所有代码与报错文案均在仓颉 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 数据处理程序
一、为什么需要接口:你其实早就用过了
回看第 14 课,我们写过这段代码:
class Counter <: Iterator<Int64> {
// ...
public func next(): Option<Int64> { ... }
}
当时说"<: 表示实现接口",没有展开。现在你回头看:for-in 循环为什么能遍历任何类型?因为编译器不关心你是 Counter 还是 IntRange,它只关心一件事——你有没有 next(): Option<T> 这个方法。
这就是接口的本质:一份"能力合同"。接口只规定"必须会做什么",不规定"怎么做"。鸟和飞机没有任何亲缘关系,但都签署了 Flyable 合同,于是塔台(调用方)可以对它们统一下达"起飞"指令。
没有接口的世界,每加一种新类型,调用方就得加一个 if 分支;有了接口,调用方只面向合同编程,新类型签个合同就能接入——这就是面向对象设计的核心:面向接口编程,而不是面向实现编程。
二、定义接口:interface 关键字
2.1 最基本的接口
interface Flyable {
func fly(): Unit
}
接口里的成员函数默认就是抽象(abstract)的——只有签名,没有函数体。也不能写 abstract 修饰符,接口成员天生抽象。
2.2 带默认实现的接口
仓颉的接口成员可以有函数体,这就是默认实现。实现类不重写它时,自动继承这份行为:
interface Speaker {
func speak(): Unit // 抽象成员:实现类必须写
func volume(): Int64 { // 默认实现:实现类可以不写
return 5
}
}
2.3 接口中的属性
接口可以声明属性(prop),承诺"实现者一定能被读出这个值":
interface Named {
prop name: String
}
接口中的 prop 只声明有没有,不存储任何数据——存储由实现类自己解决(第 16 课的 backing field)。
2.4 接口中的静态成员
接口可以带静态函数(必须有函数体),通过接口名直接调用。但注意:
🚫 接口里不能声明静态变量。
static var count: Int64 = 0会报错:unexpected variable declaration in interface body。接口不持有任何状态,静态数据请放到类里。
三、实现接口:<: 语法
3.1 单个接口
实现接口用 <:,实现成员必须显式写 public:
interface Flyable {
func fly(): Unit
}
interface Named {
prop name: String
func describe(): String {
return "I am ${name}"
}
}
class Bird <: Flyable {
public func fly(): Unit {
println("bird is flying")
}
}
🚫 实现成员忘了写
public是最常见的报错:error: a deriving member must be at least as visible as its base member note: the visibility of the base 'fly' is 'public'接口成员对外是公开的契约,实现类不能把它"藏"起来,可见性只能持平或更宽。
3.2 一个类实现多个接口
用 & 连接多个接口。这正是接口比类继承灵活的地方——类只能有一个父类,但能签任意多份合同:
class Bird <: Flyable & Named {
private var realName: String
init(name: String) {
this.realName = name
}
public prop name: String {
get() { return realName }
}
public func fly(): Unit {
println("${name} is flying")
}
}
main(): Int64 {
let b = Bird("swallow")
b.fly() // swallow is flying
println(b.describe()) // I am swallow:用的是 Named 的默认实现
return 0
}
注意两点:
Named的describe()有默认实现,Bird没写也能直接用;默认实现里引用的name,最终读的是Bird自己的 prop——默认实现是"模板",数据由实现类填。Bird内部用private var realName存储,对外只暴露只读 prop——第 16 课的封装套路在接口场景下照常工作。
想覆盖默认实现,直接写一份自己的即可(写不写 override 关键字都可以编译通过,建议写上,意图更清晰)。
3.3 struct 也能实现接口
接口不是 class 的专利。值类型 struct 同样可以签合同:
interface Describable {
func describe(): String
}
struct Temperature <: Describable {
let celsius: Float64
init(celsius: Float64) {
this.celsius = celsius
}
public func describe(): String {
return "${celsius}°C"
}
}
如果接口方法要修改 struct 自身,接口侧声明 mut func,struct 侧用 public mut func 实现(呼应第 16 课的 mut 方法):
interface Accumulable {
mut func add(v: Int64): Unit
func total(): Int64
}
struct Wallet <: Accumulable {
private var sum: Int64 = 0
public mut func add(v: Int64): Unit {
sum += v
}
public func total(): Int64 {
return sum
}
}
main(): Int64 {
var w = Wallet() // 调 mut 方法,实例必须 var 绑定
w.add(10)
w.add(20)
println("total=${w.total()}") // total=30
return 0
}
3.4 实现标准库接口:最能立竿见影的用法
仓颉标准库大量功能都是"你实现接口,我提供服务"。最实用的两个:
| 接口 | 你实现什么 | 标准库回报你什么 |
|---|---|---|
ToString | toString(): String | println(对象) 直接打印可读内容 |
Comparable<T> | compare(other: T): Ordering | 可以排序、可以比较大小 |
import std.collection.ArrayList
import std.sort.sort
class Student <: ToString & Comparable<Student> {
let name: String
let score: Int64
init(name: String, score: Int64) {
this.name = name
this.score = score
}
public func toString(): String {
return "${name}(${score})"
}
public func compare(other: Student): Ordering {
if (this.score < other.score) {
return Ordering.LT
} else if (this.score > other.score) {
return Ordering.GT
}
return Ordering.EQ
}
}
main(): Int64 {
let list = ArrayList<Student>()
list.add(Student("Alice", 88))
list.add(Student("Bob", 72))
list.add(Student("Carol", 95))
sort(list) // std.sort 的全局排序,要求元素实现 Comparable
for (s in list) {
println(s)
}
return 0
}
Bob(72)
Alice(88)
Carol(95)
Ordering 是标准库枚举,三个值 LT(小于)、EQ、GT。一次实现 Comparable,排序、最大值、二分查找全部解锁——这就是"面向接口编程"的红利。
四、接口的继承与钻石冲突
4.1 接口可以继承接口
接口之间用 <: 继承,子接口自动带上父接口的全部合同,还可以加新条款。接口还支持多继承,同样用 &:
interface A1 {
func a(): Unit
}
interface B1 {
func b(): Unit
}
interface AB <: A1 & B1 {
// 自动拥有 a() 和 b() 两份合同
}
class Impl <: AB {
public func a(): Unit { println("a") }
public func b(): Unit { println("b") }
}
实现 AB 的类必须同时兑现 a() 和 b(),一个都不能少。
4.2 钻石冲突:两份合同撞车了怎么办
经典问题:两个接口都有同名默认实现,一个类同时实现它们,听谁的?仓颉的答案:谁都不听,编译期报错,逼你表态。
interface A {
func hello(): String {
return "A"
}
}
interface B {
func hello(): String {
return "B"
}
}
class C <: A & B {
// 什么都不写
}
编译直接拒绝:
error: interface function 'hello' must be implemented in 'C'
调用 c.hello() 也会报 ambiguous match for function call 'hello'。解决办法只有一个:在 C 里自己写一份 hello(),明确选择或融合两种行为:
class C <: A & B {
public func hello(): String {
return "A+B"
}
}
这个设计避免了 C++ 菱形继承的经典灾难——冲突不允许沉默地存在。
五、类的继承:open、override 与 protected
第 16 课留了个伏笔:protected 成员"当前类及其子类可见"。子类怎么来?本节补上。
5.1 open class:默认禁止继承
仓颉的类默认不可继承,想当父类必须显式声明 open:
open class Animal {
let name: String
init(name: String) {
this.name = name
}
}
class Dog <: Animal {
init(name: String) {
super(name) // 调用父类构造函数
}
}
🚫 继承一个没有
open的类:error: super class 'Base' is not inheritable。这是刻意设计:能被继承是一种需要声明的能力,防止脆弱的继承层次被随手建立。
5.2 open func 与 override
方法同样遵循"默认封闭"原则。父类方法想被子类重写,必须标 open;子类重写必须标 override——双方都要亮明身份:
open class Animal {
protected var energy: Int64 = 100
let name: String
init(name: String) {
this.name = name
}
public open func speak(): Unit {
println("${name} makes a sound")
}
public open func eat(): Unit {
energy += 10
println("${name} eats, energy=${energy}")
}
}
class Dog <: Animal {
init(name: String) {
super(name)
}
public override func speak(): Unit {
println("${name}: woof!")
}
}
class Cat <: Animal {
init(name: String) {
super(name)
}
// speak 不 override,沿用父类行为
public override func eat(): Unit {
energy += 5
println("${name} eats delicately, energy=${energy}")
}
}
Buddy: woof!
Buddy eats, energy=110
Kitty makes a sound
Kitty eats delicately, energy=105
三个实测出来的硬规则:
open函数的可见性必须是public或protected,否则报错:the visibility of an 'open' function must be 'public' or 'protected'——私有方法对外不可见,谈何重写。- 重写非
open方法报错:error: cannot override function 'f'。 - 子类构造函数里用
super(参数)调用父类构造函数,protected成员(如energy)在子类里可以直接读写。
5.3 继承 + 接口:一个类既要又要
类最多继承一个父类,但可以同时实现多个接口,父类写在最前面:
class Circle <: Shape & Drawable {
// Shape 是父类(只能有一个),Drawable 是接口(可以有多个)
}
设计口诀:继承表达"是什么"(is-a),接口表达"能做什么"(can-do)。Circle 是一种 Shape,同时"能被画出来"。能用接口表达的抽象,优先考虑接口——合同可以签多份,父亲只能有一个。
六、多态:is、as 与动态分派
6.1 父类型引用指向子类型对象
多态(polymorphism):变量声明成父类型(父类或接口),实际装着子类型的对象。调方法时,执行的是对象真实的类型的版本,这叫动态分派:
main(): Int64 {
let shapes: Array<Shape> = [Circle("red", 1.0), Circle("blue", 2.0)]
for (s in shapes) {
println("area=${s.area()}") // 数组类型是 Shape,执行的是 Circle.area()
}
return 0
}
t2 节的 Speaker/Robot 例子同理:let s: Speaker = Robot(),调 s.volume() 执行的是 Robot 覆盖后的版本,返回 11 而不是默认的 5。声明类型决定"能调什么",实际类型决定"执行什么"——这是多态最核心的一句话。
6.2 is:类型判断
多态数组里的元素"名义上是父类型,实际上各有各的身份"。想识别真实身份,用 is:
if (n is Retryable) {
// n 实际上是支持重试的渠道
}
6.3 as:安全类型转换
确认身份后想把它"还原"成具体类型调用专属方法,用 as。仓颉的 as 是安全转换:返回 Option<T>,转换失败得到 None 而不是崩溃(呼应第 9 课):
match (n as Retryable) {
case Some(r) => println("最多重试 ${r.maxRetries()} 次")
case None => println("不支持重试")
}
哪怕不做 is 预判直接 as,最坏结果也只是 None——类型系统的安全网一直兜底。
6.4 sealed:封闭继承 + match 穷尽检查
sealed abstract class 限制子类只能定义在同一个包里。配合第 4 课的 match,编译器知道全部子类名单,能检查你有没有漏掉分支:
sealed abstract class Expr {}
class Add <: Expr {
let l: Int64
let r: Int64
init(l: Int64, r: Int64) {
this.l = l
this.r = r
}
}
class Val <: Expr {
let v: Int64
init(v: Int64) {
this.v = v
}
}
func eval(e: Expr): Int64 {
match (e) {
case x: Add => return x.l + x.r
case x: Val => return x.v
case _ => return 0
}
}
🚫
sealed只能修饰abstract class(或 interface),直接sealed class Expr {}会报错:non-abstract class cannot be modified by 'sealed'。
sealed + match 的组合是第 18 课"代数数据类型"的前奏,这里先混个脸熟。
七、实战:多渠道消息推送平台
7.1 完整代码
把本课所有知识点串起来:验证码下发系统,支持短信、邮件、App 推送三个渠道,其中短信和推送支持失败重试。
// 能力合同1:能发消息
interface Notifier {
prop channelName: String
func send(to: String, message: String): Bool
}
// 能力合同2:支持失败重试(带默认实现)
interface Retryable {
func maxRetries(): Int64 {
return 3
}
}
class SmsNotifier <: Notifier & Retryable {
public prop channelName: String {
get() { return "短信" }
}
public func send(to: String, message: String): Bool {
println("[${channelName}] -> ${to}: ${message}")
return true
}
}
class EmailNotifier <: Notifier {
public prop channelName: String {
get() { return "邮件" }
}
public func send(to: String, message: String): Bool {
println("[${channelName}] -> ${to}: ${message}")
return true
}
}
class PushNotifier <: Notifier & Retryable {
public prop channelName: String {
get() { return "App推送" }
}
public func send(to: String, message: String): Bool {
println("[${channelName}] -> ${to}: ${message}")
return true
}
// 推送通道不稳定,覆盖默认实现,多给两次机会
public override func maxRetries(): Int64 {
return 5
}
}
// 面向接口编程:不关心具体是哪家渠道
func broadcast(notifiers: Array<Notifier>, to: String, message: String): Unit {
var ok = 0
for (n in notifiers) {
let success = n.send(to, message)
if (success) {
ok++
}
// is 识别真实能力,as 安全转换后调用专属成员
if (n is Retryable) {
match (n as Retryable) {
case Some(r) => println(" (${n.channelName} 支持失败重试,最多 ${r.maxRetries()} 次)")
case None => ()
}
}
}
println("发送完成:${ok}/${notifiers.size} 个渠道成功")
}
main(): Int64 {
let channels: Array<Notifier> = [SmsNotifier(), EmailNotifier(), PushNotifier()]
broadcast(channels, "138****1234", "您的验证码是 246810")
return 0
}
7.2 运行与验证
[短信] -> 138****1234: 您的验证码是 246810
(短信 支持失败重试,最多 3 次)
[邮件] -> 138****1234: 您的验证码是 246810
[App推送] -> 138****1234: 您的验证码是 246810
(App推送 支持失败重试,最多 5 次)
发送完成:3/3 个渠道成功
关键点解读:
broadcast的参数类型是Array<Notifier>——它只认识合同,不认识任何具体渠道。明天加一个WeChatNotifier,broadcast一行都不用改。- 邮件渠道没实现
Retryable,is判断为 false,自然不会打印重试行——能力是可选的,探测是安全的。 PushNotifier覆盖了maxRetries()默认实现(3 → 5),SmsNotifier没覆盖,用默认的 3——动态分派各得其所。channelName是接口声明的 prop,三个渠道各用自己的方式提供(这里都是计算属性,无 backing field)。
7.3 调试观察
- 在
broadcast的for循环首行打断点,展开notifiers数组,观察三个元素的实际类型(数组声明类型是Notifier,但调试器里能看到真实身份); - 在
if (n is Retryable)行打断点,逐轮观察条件为 true/false 的分支走向; - 在
n.send(...)行用"步入",确认不同渠道进入了各自的send实现——亲眼见证动态分派。
八、常见问题 FAQ
Q1:接口和抽象类(abstract class)怎么选?
接口只定义能力合同,不持有状态(不能有成员变量);抽象类可以有字段、构造函数和完整实现。一句话:跨家族的通用能力用接口(Flyable、Comparable),同一家族的公共骨架用抽象类。仓颉抽象类的完整用法会在工程化模块展开,本课实战只用接口就够了。
Q2:实现接口成员时,override 到底写不写?
实现抽象成员时不用写;覆盖接口的默认实现时,写不写都能编译通过(1.2.0 实测),建议写上表达"我是有意覆盖"的意图。但重写父类的 open 方法时,override 是强制的,不写报错。
Q3:接口里能有 let/var 成员变量吗?
不能。接口只能有:抽象/默认实现的 func、prop 声明、带实现的 static func。状态永远属于实现类自己。
Q4:一个类能继承多个类吗?
不能,仓颉是单继承。想复用多个来源的行为,用"继承一个父类 + 实现多个接口"的组合。这正是接口存在的主要理由之一。
Q5:as 转换失败会怎样?
什么都不发生——返回 None。仓颉没有"强制转换失败就抛异常"的运算符,配合 match 或 if-let 思路处理两种情况即可。这也让 is + as 的组合成为标准姿势。
Q6:struct 实现接口后,能通过接口类型多态调用吗?
可以。let d: Describable = Temperature(36.5) 完全合法。但要记住第 15 课的老话:struct 是值类型,赋给接口变量时发生了装箱(拷贝),之后通过接口修改不会影响原变量。
Q7:父类已经实现了某接口,子类还要重新声明吗?
不用。子类自动继承父类对接口的实现,也可以 override 掉父类的实现(前提是父类把该成员声明为 open)。
Q8:默认实现里能访问实现类的成员吗?
能,但只能通过接口自己声明的合同访问。比如 Named.describe() 里用了 name——因为 name 本身就是 Named 声明的 prop。默认实现不能凭空访问实现类私有的成员。
九、课后练习
- 定义接口
Shape2D { func area(): Float64; func perimeter(): Float64 },让Rectangle和Circle两个 class 实现它,写一个func printReport(s: Shape2D)打印面积和周长,用多态数组测试。 - 给练习 1 加一个带默认实现的成员
func describe(): String,返回"面积=${area()}, 周长=${perimeter()}"。让Circle覆盖它,额外打印半径。 - 写一个
class Vector2 <: ToString & Comparable<Vector2>(x、y两个 Float64 字段),compare按模长比较。创建 4 个向量放进ArrayList,用std.sort.sort排序后打印,验证顺序。 - 定义
interface Flyable和interface Swimmable,各带一个有默认实现的move(): String。让class Duck同时实现两者——先直接编译,观察钻石冲突报错,再在Duck里写自己的move()解决冲突。 - 综合:设计一个"动物园"小程序。
open class Animal(protected var hunger: Int64、public open func feed())作为父类;interface Showable { func perform(): Unit }表示"能表演"。Dog、Cat继承Animal,其中只有Dog实现Showable。写一个func eveningShow(animals: Array<Animal>):对所有动物feed(),再对其中"能表演的"(is/as)调用perform()。
下节预告
接口解决了"能力合同"问题,但还有一类常见需求没被覆盖:一个值"要么是成功结果,要么是错误原因"“要么是圆,要么是矩形,要么是三角形”——这种封闭的二选一/多选一类型,用 class 继承写起来又重又不安全。第 18 课 枚举 enum、代数数据类型与 match 模式匹配,看仓颉如何用 enum 优雅地表达"有限种可能",并让 match 发挥真正的威力。
系列说明:本系列基于 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)