【仓颉语言入门 · 第17课】接口 interface 与实现第 15、16 课把 struct/class 的骨架和血肉都搭好了。但还差最后一块拼图类与类之间怎么约定能力怎么让一只鸟和一架飞机共享能飞这个抽象怎么写一个函数对所有能发消息的东西统一处理答案就是接口interface——本课讲清仓颉的接口定义、实现、继承与多态顺便兑现第 16 课埋下的伏笔protected和类的继承。本文所有代码与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行。目录系列导航整套路线共7 个模块、30 课模块课次内容一、环境与入门0105环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环二、常用类型与数据组织0610字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理三、函数与函数式1114函数、Lambda 与高阶函数、闭包、迭代器与惰性序列四、面向对象与类型系统1520struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展五、工程化与标准库2125cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试六、并发编程2628线程、Channel 通道与同步原语、并发实战七、项目实战2930命令行小工具、GeoJSON 数据处理实战环境搭建与第一个仓颉程序变量与常量let / var 与基本数据类型运算符与标准输入输出分支结构if 与 match 表达式循环结构while / for / Range字符串详解与字符串插值数组 Array 与区间 Range集合框架ArrayList、HashMap、HashSet可空类型?与 Option错误处理异常机制与 Result函数定义、参数与返回值Lambda 与高阶函数闭包、作用域与函数类型迭代器 Iterator 与 Sequence结构体 struct 与类 class构造函数、属性与方法接口 interface 与实现本文枚举 enum、代数数据类型与 match 模式匹配泛型编程扩展、类型别名与可见性控制cjpm 包管理与多文件项目组织文件与目录 IOJSON 处理结合 stdx 扩展库网络编程入门单元测试并发基础线程的创建与等待Channel 通道与同步原语并发实战多线程任务处理实战一带文件持久化的命令行小工具实战二GeoJSON 数据处理程序一、为什么需要接口你其实早就用过了回看第 14 课我们写过这段代码class Counter : IteratorInt64 { // ... public func next(): OptionInt64 { ... } }当时说:表示实现接口没有展开。现在你回头看for-in循环为什么能遍历任何类型因为编译器不关心你是Counter还是IntRange它只关心一件事——你有没有next(): OptionT这个方法。这就是接口的本质一份能力合同。接口只规定必须会做什么不规定怎么做。鸟和飞机没有任何亲缘关系但都签署了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 单个接口实现接口用:实现成员必须显式写publicinterface 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 funcstruct 侧用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()}) // total30 return 0 }3.4 实现标准库接口最能立竿见影的用法仓颉标准库大量功能都是你实现接口我提供服务。最实用的两个接口你实现什么标准库回报你什么ToStringtoString(): Stringprintln(对象)直接打印可读内容ComparableTcompare(other: T): Ordering可以排序、可以比较大小import std.collection.ArrayList import std.sort.sort class Student : ToString ComparableStudent { 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 ArrayListStudent() 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 AB } }这个设计避免了 C 菱形继承的经典灾难——冲突不允许沉默地存在。五、类的继承open、override 与 protected第 16 课留了个伏笔protected成员当前类及其子类可见。子类怎么来本节补上。5.1 open class默认禁止继承仓颉的类默认不可继承想当父类必须显式声明openopen 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, energy110 Kitty makes a sound Kitty eats delicately, energy105三个实测出来的硬规则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: ArrayShape [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类型判断多态数组里的元素名义上是父类型实际上各有各的身份。想识别真实身份用isif (n is Retryable) { // n 实际上是支持重试的渠道 }6.3 as安全类型转换确认身份后想把它还原成具体类型调用专属方法用as。仓颉的as是安全转换返回OptionT转换失败得到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。sealedmatch的组合是第 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: ArrayNotifier, 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: ArrayNotifier [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的参数类型是ArrayNotifier——它只认识合同不认识任何具体渠道。明天加一个WeChatNotifierbroadcast一行都不用改。邮件渠道没实现Retryableis判断为 false自然不会打印重试行——能力是可选的探测是安全的。PushNotifier覆盖了maxRetries()默认实现3 → 5SmsNotifier没覆盖用默认的 3——动态分派各得其所。channelName是接口声明的 prop三个渠道各用自己的方式提供这里都是计算属性无 backing field。7.3 调试观察在broadcast的for循环首行打断点展开notifiers数组观察三个元素的实际类型数组声明类型是Notifier但调试器里能看到真实身份在if (n is Retryable)行打断点逐轮观察条件为 true/false 的分支走向在n.send(...)行用步入确认不同渠道进入了各自的send实现——亲眼见证动态分派。八、常见问题 FAQQ1接口和抽象类abstract class怎么选接口只定义能力合同不持有状态不能有成员变量抽象类可以有字段、构造函数和完整实现。一句话跨家族的通用能力用接口Flyable、Comparable同一家族的公共骨架用抽象类。仓颉抽象类的完整用法会在工程化模块展开本课实战只用接口就够了。Q2实现接口成员时override到底写不写实现抽象成员时不用写覆盖接口的默认实现时写不写都能编译通过1.2.0 实测建议写上表达我是有意覆盖的意图。但重写父类的 open 方法时override是强制的不写报错。Q3接口里能有let/var成员变量吗不能。接口只能有抽象/默认实现的func、prop 声明、带实现的static func。状态永远属于实现类自己。Q4一个类能继承多个类吗不能仓颉是单继承。想复用多个来源的行为用继承一个父类 实现多个接口的组合。这正是接口存在的主要理由之一。Q5as转换失败会怎样什么都不发生——返回None。仓颉没有强制转换失败就抛异常的运算符配合match或if-let思路处理两种情况即可。这也让isas的组合成为标准姿势。Q6struct 实现接口后能通过接口类型多态调用吗可以。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 ComparableVector2x、y两个 Float64 字段compare按模长比较。创建 4 个向量放进ArrayList用std.sort.sort排序后打印验证顺序。定义interface Flyable和interface Swimmable各带一个有默认实现的move(): String。让class Duck同时实现两者——先直接编译观察钻石冲突报错再在Duck里写自己的move()解决冲突。综合设计一个动物园小程序。open class Animalprotected var hunger: Int64、public open func feed()作为父类interface Showable { func perform(): Unit }表示能表演。Dog、Cat继承Animal其中只有Dog实现Showable。写一个func eveningShow(animals: ArrayAnimal)对所有动物feed()再对其中能表演的is/as调用perform()。下节预告接口解决了能力合同问题但还有一类常见需求没被覆盖一个值要么是成功结果要么是错误原因“要么是圆要么是矩形要么是三角形”——这种封闭的二选一/多选一类型用 class 继承写起来又重又不安全。第 18 课枚举 enum、代数数据类型与 match 模式匹配看仓颉如何用enum优雅地表达有限种可能并让match发挥真正的威力。系列说明本系列基于 Windows 平台 CIDE 仓颉 SDK1.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
