ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

【仓颉语言入门 · 第18课】

【仓颉语言入门 · 第18课】 【仓颉语言入门 · 第18课】枚举 enum、代数数据类型与 match 模式匹配第 17 课结尾留了一个问题一个值要么是成功结果要么是错误原因“要么是圆要么是矩形要么是三角形”这种封闭的多选一类型用 class 继承写起来又重又不安全。本课的主角enum枚举正是答案——仓颉的枚举不是其他语言里给整数起名字的弱枚举而是函数式语言里的代数数据类型ADT构造器可以携带不同类型、不同个数的数据再配合match的穷尽匹配让编译器帮你保证每一种可能都被处理了。本文所有代码与报错文案均在仓颉 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 数据处理程序一、为什么需要枚举从魔法数字说起初学编程时表示春夏秋冬你可能会这样写// 约定1春 2夏 3秋 4冬 let season 2问题在于这个约定只存在于注释里编译器完全不知道1、2、3、4是合法值。于是let season 99也能编译通过传错了、拼错了都要等运行时才暴露。枚举把只能取这几个值从注释变成编译器强制的约束enum Season { | Spring | Summer | Autumn | Winter }Season类型的变量永远只能是这四个值之一。花括号里列出的每一个可能取值叫做一个构造器constructor多个构造器之间用|分隔第一个|可写可不写。注意这里的构造器和 class 里的构造函数init不是一回事它指的是枚举类型的一个可能取值。二、定义和使用枚举2.1 创建枚举值用类型名.构造器创建一个枚举值当没有同名变量/函数冲突时也可以直接写构造器名enum Season { | Spring | Summer | Autumn | Winter } func describe(s: Season): String { match (s) { case Spring 春 case Summer 夏 case Autumn 秋 case Winter 冬 } } main(): Int64 { let s1 Season.Summer // 完整写法最稳妥 let s2: Season Summer // 简写类型明确且无歧义时可省略类型名 println(describe(s1)) // 夏 println(describe(s2)) // 夏——两种写法得到的是同一个枚举值 return 0 }注意仓颉的枚举默认不支持运算符仓颉也没有连写三个等号会直接报语法错误判断枚举值的标准方式是match确实需要时请实现Equatable接口见 6.4 节。2.2 枚举里可以定义方法枚举体里除了构造器还能定义成员函数。方法内部用match (this)判断我现在是哪个构造器enum RGBColor { | Red | Green | Blue(UInt8) // 有参构造器携带一个亮度下一节细讲 public func describe(): String { match (this) { case Red 红色 case Green 绿色 case Blue(b) 蓝色(亮度${b}) } } } main(): Int64 { println(Red.describe()) // 红色 println(Blue(150).describe()) // 蓝色(亮度150) return 0 }2.3 枚举只能定义在顶层枚举只能写在源文件的顶层作用域不能嵌套在函数、class 或其他枚举里。需要被多个文件共用时和其他顶层声明一样放在包里即可。三、match 与枚举穷尽匹配第 4 课已经用过match做分支这一节把它和枚举配对的完整能力讲透。3.1 match 是表达式有返回值和if一样match是表达式可以直接算出一个值赋给变量main(): Int64 { let s Season.Summer let text match (s) { case Spring 春 case Summer 夏 case Autumn 秋 case Winter 冬 } println(text) // 夏 return 0 }3.2 穷尽性漏一个分支编译就报错match最值钱的特性是穷尽检查exhaustive所有构造器必须都被覆盖。故意漏掉Wintererror: non-exhaustive patterns # note: Winter is not covered将来你给枚举新增一个构造器所有没处理它的match会集体编译报错——这相当于编译器帮你把所有需要同步修改的地方一个个揪出来杜绝加了新状态却忘了处理的线上事故。3.3 通配符_我不关心具体是谁不想逐一列出时最后一个分支用_兜底它能匹配任何值match (s) { case Summer 放暑假 case _ 正常上学 // 春/秋/冬 都走这里 }_还能用在有参构造器上表示这个参数我不要case Blue(_) ...。3.4 一条 case 合并多个模式相邻分支行为相同时用|在一条case里合并enum LogLevel { | Debug | Info | Warn | Error | Fatal } func needAlert(level: LogLevel): Bool { match (level) { case Error | Fatal true // 错误和致命错误都要告警 case _ false } }小提示构造器名Error恰好与标准库的异常基类Error同名。在case里有明确的类型上下文不受影响但在普通表达式中直接写Error如needAlert(Error)可能被解析成标准库类型而报错这时写全名LogLevel.Error即可。3.5 没有匹配值的 match更清爽的 if-else 链match后面可以不跟值此时每个case后面直接写一个 Bool 表达式相当于自上而下的 if-else 链let score 85 let grade match { case score 90 优 case score 60 及格 case _ 不及格 } println(等级${grade}) // 等级及格3.6 只关心一种情况if-let满屏分支有时只是为了取一个值。if-let在只关心某一种构造器时比 match 简洁注意箭头是-enum MyOpt { | SomeS(String) | NoneS } main(): Int64 { let o: MyOpt SomeS(hello) if (let SomeS(text) - o) { println(拿到字符串${text}) // 拿到字符串hello } else { println(是空的) } return 0 }标准库Option第 9 课同样可以这样写if (let Some(v) - opt) { ... }。第 9 课我们用的是isSome()getOrThrow()的姿势if-let 是它更简洁的替代——实测在 SDK 1.2.0 下两种写法都可用。四、有参构造器关联值ADT 的核心枚举真正强大的地方是不同构造器可以携带不同类型、不同个数的数据这叫关联值associated value。4.1 同一个枚举每种形态带不同的数据用一个Shape枚举表示三种图形圆只需要半径矩形需要宽和高点什么都不需要enum Shape { | Circle(Float64) // 半径 | Rectangle(Float64, Float64) // 宽、高 | Point } func area(s: Shape): Float64 { match (s) { case Circle(r) 3.14159265 * r * r case Rectangle(w, h) w * h case Point 0.0 } } main(): Int64 { println(${area(Circle(2.0))}) // 12.566371 println(${area(Rectangle(3.0, 4.0))}) // 12.000000 println(${area(Point)}) // 0.000000 return 0 }case Circle(r)中的r是绑定模式匹配成功时构造器携带的数据被绑定到变量r分支体内直接使用。对比用 class 实现得写Shape父类 Circle/Rectangle/Point三个子类 三个文件的仪式感。而枚举把一组封闭的形态压缩进一个类型数据和分支都集中可见。4.2 同名构造器靠参数个数区分仓颉允许一个枚举里出现同名构造器只要参数个数不同无参算 0 个enum MyColor { | White | Black | White(UInt8) | Black(UInt8) } match (c) { case White println(无参 White) case White(v) println(带亮度 ${v} 的 White) case _ println(其他) }case White匹配无参构造器case White(v)匹配有参构造器编译器靠参数个数区分。4.3 守卫 where匹配成功后再加条件模式后面可以跟where 条件注意不是if模式匹配上且条件为 true 时分支才命中。case 自上而下第一个命中的胜出func describe(s: Shape): String { match (s) { case Circle(r) where r 10.0 大圆 case Circle(_) 小圆 // 半径 10 的圆落到这里 case Rectangle(w, h) where w h 正方形 case Rectangle(_, _) 长方形 case Point 一个点 } }实测输出describe(Circle(20.0))是大圆describe(Rectangle(5.0, 5.0))是正方形。4.4 嵌套枚举一层套一层地解构构造器的参数类型可以是另一个枚举match 时可以一层套一层地解构enum PayInfo { | Unpaid | Paid(Int64) // 支付金额分 } enum OrderStatus { | Created | Pay(PayInfo) // 支付中里面再带一个支付信息 | Closed } func statusText(s: OrderStatus): String { match (s) { case Created 待支付 case Pay(Unpaid) 支付处理中 case Pay(Paid(amount)) 已支付 ${amount} 分 // 两层一次性拆开 case Closed 已关闭 } }Pay(Paid(9900))直接在一条 case 里拆出最里层的amount输出已支付 9900 分。五、递归枚举表达式求值器构造器的参数类型还可以是枚举自己这叫递归枚举。经典例子是算术表达式一个表达式要么是数字要么是两个子表达式相加/相减/相乘enum Expr { | Num(Int64) | Add(Expr, Expr) | Sub(Expr, Expr) | Mul(Expr, Expr) } func eval(e: Expr): Int64 { match (e) { case Num(n) n case Add(a, b) eval(a) eval(b) case Sub(a, b) eval(a) - eval(b) case Mul(a, b) eval(a) * eval(b) } }(1 2) * (10 - 3)用嵌套构造器写出来再交给递归的evalmain(): Int64 { let e Mul(Add(Num(1), Num(2)), Sub(Num(10), Num(3))) println((12)*(10-3) ${eval(e)}) // (12)*(10-3) 21 return 0 }这就是 JSON 解析器、AST 抽象语法树、状态机等数据结构的标准写法——数据用递归枚举描述处理用递归 match 完成而且编译器保证每一种节点都被处理。六、泛型枚举你其实早就认识两个6.1 亲手写一个 Option第 9 课的OptionT本质上就是一个带泛型参数的枚举enum MyOptionT { | MySome(T) | MyNone func unwrapOr(def: T): T { match (this) { case MySome(v) v case MyNone def } } } main(): Int64 { let a: MyOptionInt64 MySome(42) let n: MyOptionInt64 MyNone println(${a.unwrapOr(0)}, ${n.unwrapOr(0)}) // 42, 0 return 0 }标准库的enum OptionT { Some(T) | None }与此一模一样只是多了??、?.、getOrThrow等语法糖第 9 课。6.2 亲手写一个 Result第 10 课我们实测过当前版本截至 SDK 1.2.0的标准库还没有内置 Result直接写Result...会报undeclared type name Result当时我们用一个普通枚举手写了结果类型。有了泛型可以把它写得更通用——成功装一种数据失败装另一种这也是将来官方ResultT, E的形态enum MyResultT, E { | Ok(T) | Err(E) } func divide(a: Int64, b: Int64): MyResultInt64, String { if (b 0) { return Err(除数不能为 0) } return Ok(a / b) } main(): Int64 { match (divide(10, 3)) { case Ok(v) println(成功${v}) // 成功3 case Err(msg) println(失败${msg}) } match (divide(10, 0)) { case Ok(v) println(成功${v}) case Err(msg) println(失败${msg}) // 失败除数不能为 0 } return 0 }看懂这两段你就彻底理解了第 9、10 课可空和错误都不是什么特殊语法——Option是标准库提供的枚举而Result形态的结果类型我们自己用一个枚举也能写出来配合 match 强制调用方处理没有值/出错了的分支。等将来 SDK 内置Result把MyResult/Ok/Err换个名字即可完成迁移第 10 课已预告过这一点。6.3 枚举也能实现接口和 class 一样枚举用:实现接口照样能多态interface Drawable { func draw(): Unit } enum TrafficLight : Drawable { | Red | Yellow | Green public func draw(): Unit { match (this) { case Red println(画红灯) case Yellow println(画黄灯) case Green println(画绿灯) } } }let lights: ArrayDrawable [Red, Yellow, Green]后统一调l.draw()走的就是第 17 课的接口多态。6.4 想打印、想 比较实现 ToString / Equatable枚举默认不能直接println也不能用比较编译器会分别报mismatched types和invalid binary operator 。需要这两个能力时实现对应接口enum Coin : EquatableCoin ToString { | Penny | Nickel | Dime | Quarter // Equatable 要求实现 ! 会自动获得 public operator func (other: Coin): Bool { match ((this, other)) { // 元组模式同时匹配两个值 case (Penny, Penny) true case (Nickel, Nickel) true case (Dime, Dime) true case (Quarter, Quarter) true case _ false } } // ToString 要求实现 toString 后即可 println / 字符串插值 public func toString(): String { match (this) { case Penny 1美分 case Nickel 5美分 case Dime 10美分 case Quarter 25美分 } } }实测Penny Penny为true、Penny ! Dime为true两者确实不同!由接口自动提供、println(Nickel)输出5美分。6.5 Comparable 与 Ordering让枚举能排序实现ComparableT需要写compare方法它的返回值是标准库枚举Ordering只有三个构造器LT小于、EQ等于、GT大于。实现后自动获得、、全套比较enum Priority : ComparablePriority { | Low | Medium | High func rank(): Int64 { match (this) { case Low 1 case Medium 2 case High 3 } } public func compare(other: Priority): Ordering { let d this.rank() - other.rank() if (d 0) { return Ordering.LT } else if (d 0) { return Ordering.GT } else { return Ordering.EQ } } }排序沿用第 7、12 课的全局函数sort(数组, by: { ... })import std.sort.*比较器直接委托给comparesort(tasks, by: { a: Task, b: Task b.priority.compare(a.priority) })七、实战待办事项状态机把本课知识点串起来一个待办清单任务有优先级可排序的枚举和状态关联值携带进度、完成时间的枚举所有状态流转都靠 match 保证合法。package todo import std.sort.* // 优先级实现 Comparable自带 compare 产出 Ordering enum Priority : ComparablePriority { | Low | Medium | High func rank(): Int64 { match (this) { case Low 1 case Medium 2 case High 3 } } public func compare(other: Priority): Ordering { let d this.rank() - other.rank() if (d 0) { return Ordering.LT } else if (d 0) { return Ordering.GT } else { return Ordering.EQ } } func text(): String { match (this) { case Low 低 case Medium 中 case High 高 } } } // 任务状态关联值携带进度和完成时间 enum Status { | Pending // 未开始 | Doing(UInt8) // 进行中0~100 | Done(Int64) // 已完成携带完成时间戳 func text(): String { match (this) { case Pending 未开始 case Doing(p) 进行中 ${p}% case Done(ts) 已完成(时间戳${ts}) } } } class Task { public let id: Int64 public let title: String public let priority: Priority public var status: Status public init(id: Int64, title: String, priority: Priority) { this.id id this.title title this.priority priority this.status Pending } } // 开始任务只有未开始能开始 func start(t: Task): Bool { match (t.status) { case Pending t.status Doing(0) return true case _ return false } } // 更新进度只有进行中能更新且取值合法 func updateProgress(t: Task, p: UInt8): Bool { match (t.status) { case Doing(_) where p 100 t.status Doing(p) return true case _ return false } } // 完成任务已完成的不能重复完成其他状态都可完成 func finish(t: Task, ts: Int64): Bool { match (t.status) { case Done(_) false case _ t.status Done(ts) return true } } // 紧急任务高优先级且尚未完成 func isUrgent(t: Task): Bool { match (t.status) { case Done(_) false case _ t.priority High } } func oneLine(t: Task): String { return #${t.id} [${t.priority.text()}] ${t.title} —— ${t.status.text()} } main(): Int64 { let tasks [ Task(1, 写第18课教程, High), Task(2, 回复评论区, Low), Task(3, 修复 LSP 崩溃, High), Task(4, 整理发布说明, Medium) ] // 模拟操作 start(tasks[0]) updateProgress(tasks[0], 60) start(tasks[2]) finish(tasks[2], 1727000000) finish(tasks[3], 1727000100) println( 全部任务 ) for (t in tasks) { println(oneLine(t)) } println(\n 紧急任务高优先级且未完成) for (t in tasks) { if (isUrgent(t)) { println(oneLine(t)) } } // 按优先级从高到低排序比较器委托给 Priority.compare sort(tasks, by: { a: Task, b: Task b.priority.compare(a.priority) }) println(\n 按优先级从高到低 ) for (t in tasks) { println(oneLine(t)) } return 0 }运行结果 全部任务 #1 [高] 写第18课教程 —— 进行中 60% #2 [低] 回复评论区 —— 未开始 #3 [高] 修复 LSP 崩溃 —— 已完成(时间戳1727000000) #4 [中] 整理发布说明 —— 已完成(时间戳1727000100) 紧急任务高优先级且未完成 #1 [高] 写第18课教程 —— 进行中 60% 按优先级从高到低 #1 [高] 写第18课教程 —— 进行中 60% #3 [高] 修复 LSP 崩溃 —— 已完成(时间戳1727000000) #4 [中] 整理发布说明 —— 已完成(时间戳1727000100) #2 [低] 回复评论区 —— 未开始这个例子值得体会的设计每个操作函数返回Bool表示这次状态流转是否合法非法流转在编译期就被 match 的分支结构框死了——比如对Done的任务再finish不用任何 if 判空天然返回 false。这就是用类型表达业务规则的威力。八、常见问题 FAQQ1仓颉枚举为什么没有Mon 1这种原始值仓颉枚举是代数数据类型不是其他语言里整数的别名构造器不自动对应整数写Mon 1会直接语法报错。想要数字编号自己写一个match方法转换如本课的rank()想让构造器携带数据用有参构造器Blue(UInt8)。这反而比原始值强大得多——关联值可以是任意类型、任意个数。Q2枚举为什么不能直接println、不能用枚举默认不实现任何接口。打印需要: ToString并写toString()比较需要: EquatableT并写operator func 实现后!自动可用想排序就实现ComparableT的compare。日常最简单的判断方式其实是match多数场景根本不需要。Q3枚举能写在函数里面吗不能枚举只能定义在源文件顶层。函数内部需要临时的多选一时直接用顶层枚举或 match 字面分支即可。Q4case 的书写顺序重要吗重要。match 自上而下匹配第一个命中的分支胜出后立刻跳出。所以带where守卫的小范围分支必须写在大范围分支前面Circle(r) where r 10要在Circle(_)之前否则永远轮不到它。Q5报non-exhaustive patterns怎么办说明有构造器没覆盖报错信息的note会直接告诉你漏了谁如Winter is not covered。要么补全分支要么在最后加case _ 兜底。不要无脑加_——穷尽检查是编译器送的安全网显式列全每个构造器通常更安全。Q6match 和 if-let 什么时候用哪个需要覆盖所有/多种构造器、各分支行为不同时用match只关心某一种构造器、其他情况统一处理时用if-let。处理Option第 9 课或手写的结果枚举第 10 课时这两者最常用。Q7枚举最后的...是什么那是非穷举枚举声明表示未来可能追加新构造器enum ExtendedColor { | Red | Green | Blue | ... }对它做 match 时必须写case _兜底编译器不认为三个构造器就是全部这样将来新增构造器时旧代码仍能编译。自己的业务枚举形态固定时不需要它。Q8枚举和第 17 课的 sealed 类怎么选形态封闭、只装数据、几乎没有各自的行为时用枚举订单状态、表达式树、Option/Result各形态需要独立的字段、复杂方法或被外部继承时用 sealed 类。经验法则先问这是不是一组数据形态——是就用枚举。九、课后练习定义枚举enum Weekday { | Mon | Tue | Wed | Thu | Fri | Sat | Sun }写函数isWeekend(d: Weekday): Bool用多模式合并再写一个text(d)返回中文星期。给本课的Shape枚举增加Triangle(Float64, Float64, Float64)三边用海伦公式√(p(p-a)(p-b)(p-c))p(abc)/2求面积在area和describe中补全分支故意删掉一个分支观察non-exhaustive patterns报错。给第五节的Expr增加Div(Expr, Expr)除法构造器除数求值为 0 时让eval返回OptionInt64None表示除零错误调用方用 match 处理两种结果。定义enum HttpStatus { | Ok(Int64) | Redirect(Int64, String) | Error(Int64, String) }实现ToString用嵌套绑定/多模式让200/204都显示成功。扩展实战项目给Status增加Paused(UInt8)暂停时保留进度新增pause(t)只有 Doing 能暂停和resume(t)只有 Paused 能恢复为 Doing。编译后按编译器的not covered提示把所有需要补分支的函数逐个补全——亲手感受一遍穷尽检查的价值。下节预告本课的MyOptionT、MyResultT, E已经提前用了泛型类型本身留一个占位符T使用时再决定装什么类型。第 19 课泛型编程将系统讲解泛型函数、泛型类/结构体、泛型约束where T : ComparableT让你写出一份逻辑任意类型复用的容器与算法——第 8 课的ArrayListT、第 14 课的IteratorT背后都是它。系列说明本系列基于 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
返回列表