【仓颉语言入门 · 第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~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 数据处理程序

一、为什么需要接口:你其实早就用过了

回看第 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 实现标准库接口:最能立竿见影的用法

仓颉标准库大量功能都是"你实现接口,我提供服务"。最实用的两个:

接口你实现什么标准库回报你什么
ToStringtoString(): Stringprintln(对象) 直接打印可读内容
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

三个实测出来的硬规则:

  1. open 函数的可见性必须是 public 或 protected,否则报错:the visibility of an 'open' function must be 'public' or 'protected'——私有方法对外不可见,谈何重写。
  2. 重写非 open 方法报错:error: cannot override function 'f'。
  3. 子类构造函数里用 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 调试观察

  1. 在 broadcast 的 for 循环首行打断点,展开 notifiers 数组,观察三个元素的实际类型(数组声明类型是 Notifier,但调试器里能看到真实身份);
  2. 在 if (n is Retryable) 行打断点,逐轮观察条件为 true/false 的分支走向;
  3. 在 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。默认实现不能凭空访问实现类私有的成员。


九、课后练习

  1. 定义接口 Shape2D { func area(): Float64; func perimeter(): Float64 },让 Rectangle 和 Circle 两个 class 实现它,写一个 func printReport(s: Shape2D) 打印面积和周长,用多态数组测试。
  2. 给练习 1 加一个带默认实现的成员 func describe(): String,返回 "面积=${area()}, 周长=${perimeter()}"。让 Circle 覆盖它,额外打印半径。
  3. 写一个 class Vector2 <: ToString & Comparable<Vector2>(x、y 两个 Float64 字段),compare 按模长比较。创建 4 个向量放进 ArrayList,用 std.sort.sort 排序后打印,验证顺序。
  4. 定义 interface Flyable 和 interface Swimmable,各带一个有默认实现的 move(): String。让 class Duck 同时实现两者——先直接编译,观察钻石冲突报错,再在 Duck 里写自己的 move() 解决冲突。
  5. 综合:设计一个"动物园"小程序。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
Logo

一站式 AI 云服务平台

更多推荐