【仓颉语言入门 · 第15课】结构体 struct 与类 class

前 14 课,我们用"变量 + 函数"搭出了全部逻辑——变量存数据,函数操作数据。但数据一多就乱了:一个学生有姓名、年龄、学号、成绩;一本书有标题、作者、价格、库存……难道全都摊成一个个独立的变量?这节课进入面向对象的世界:用 struct(结构体)和 class(类)把"数据 + 操作数据的方法"打成一个包。本课重点讲清一件事——值类型与引用类型的根本差异,这是整个面向对象模块的地基。

本文所有代码与报错文案均在仓颉 SDK 1.1.3 下逐行实测编译运行。途中会澄清三个高频误区:① let 绑定的 struct,其 var 字段也不能改(值类型整体不可变);② struct 的方法不能修改 this 的字段,只能返回新值(仓颉没有 mutating 关键字);③ class 的方法可以直接修改 var 字段,无需任何修饰符。


目录(系列导航)

整套路线共 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 数据处理实战 |

  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 数据处理程序

一、从"散落的数据"到"打包的数据"

假设要在程序里描述一个二维坐标点。目前我们只会这样写:

main(): Int64 {
    let px = 3
    let py = 4
    println("x=${px}, y=${py}")
    return 0
}

一个点两个变量还能接受。如果是一个学生呢?姓名、年龄、学号、三门成绩……五个变量起步。如果是一个班 50 个学生,那就是 250 个变量,光命名都要疯掉。

更麻烦的是"传递":写一个函数计算两点距离,得传 4 个参数(x1, y1, x2, y2);写一个函数判断学生是否及格,得传成绩……参数列表越来越长,而且参数顺序一写反就出 bug。

面向对象的第一步:把属于同一个事物的数据打包成一个整体。仓颉提供两种打包方式:

  • struct(结构体):值类型,适合描述"简单的值"
  • class(类):引用类型,适合描述"有身份、有行为的对象"

两者语法几乎一样,但运行时行为天差地别——这正是本课的核心。


二、struct:值类型的打包

2.1 定义一个 struct

struct Point {
    var x: Int64
    var y: Int64

    init(x: Int64, y: Int64) {
        this.x = x
        this.y = y
    }
}

拆解:

  • struct Point { ... }:定义一个名为 Point 的结构体;
  • var x: Int64 / var y: Int64:两个字段(field),存储数据;
  • init(x: Int64, y: Int64) { ... }构造函数,创建实例时调用,用 this.x = x 把参数赋给字段;
  • this:当前实例的引用,类似 Java/Kotlin 的 this、Python 的 self

2.2 创建实例与访问字段

main(): Int64 {
    let p = Point(3, 4)
    println("x = ${p.x}, y = ${p.y}")
    return 0
}
x = 3, y = 4

类型名(参数) 创建实例,用 .字段名 访问字段。

2.3 struct 是值类型:赋值即复制

这是 struct 最核心的特性。看代码:

main(): Int64 {
    var p1 = Point(1, 2)
    var p2 = p1          // 把 p1 赋值给 p2
    p2.x = 100           // 修改 p2
    println("p1.x = ${p1.x}, p2.x = ${p2.x}")
    return 0
}
p1.x = 1, p2.x = 100

p2 = p1 时,仓颉把 p1值完整复制了一份p2。之后 p2p1 是两个完全独立的点,改 p2.x 不影响 p1.x

📌 这就是"值类型"的含义:变量里存的是数据本身,赋值时复制数据。Int64Float64BoolChar 这些基本类型也都是值类型,struct 跟它们是同一类。


三、class:引用类型的打包

3.1 定义一个 class

语法和 struct 几乎一模一样,只是关键字换成 class

class Person {
    let name: String
    var age: Int64

    init(name: String, age: Int64) {
        this.name = name
        this.age = age
    }
}

3.2 创建实例

main(): Int64 {
    let p = Person("Alice", 30)
    println("${p.name}, ${p.age}")
    return 0
}
Alice, 30

创建方式、字段访问和 struct 完全一样。差别藏在赋值行为里。

3.3 class 是引用类型:赋值即共享

main(): Int64 {
    let a = Person("Alice", 30)
    let b = a            // 把 a 赋值给 b
    b.age = 99           // 修改 b
    println("a.age = ${a.age}, b.age = ${b.age}")
    return 0
}
a.age = 99, b.age = 99

b = a 时,仓颉没有复制对象,而是让 ba 指向同一个 Person 实例。所以改 b.agea.age 也跟着变——它们是同一个人的两个名字。

📌 这就是"引用类型"的含义:变量里存的是指向对象的引用(地址),赋值时复制的是引用,对象本身只有一份。第 13 课用 class 当闭包的可变容器,靠的就是这个特性。


四、值类型 vs 引用类型:一张表说清

维度struct(值类型)class(引用类型)
变量里存的数据本身指向对象的引用
赋值复制一份新数据共享同一个对象
传参复制一份传入函数传入引用,函数内可改原对象
内存位置栈(小对象)
能否继承不能能(第 17 课讲)
适合描述坐标、颜色、日期、金额等"值"学生、订单、账户等"有身份的对象"

4.1 传参时的差异

struct 传参是复制,函数内改不了外面的:

func bumpX(p: Point): Point {
    var q = p
    q.x = q.x + 1
    return q
}

main(): Int64 {
    let p = Point(1, 2)
    let p2 = bumpX(p)
    println("p.x = ${p.x}, p2.x = ${p2.x}")
    return 0
}
p.x = 1, p2.x = 2

bumpX 拿到的是 p 的副本,改副本不影响原 p,要改得靠返回值。

class 传参是共享,函数内直接改原对象:

func birthday(p: Person) {
    p.age += 1
}

main(): Int64 {
    let a = Person("Alice", 30)
    birthday(a)
    println("${a.name} 现在 ${a.age} 岁")
    return 0
}
Alice 现在 31 岁

birthday 里改 p.age,外面的 a.age 也变了——因为是同一个对象。

⚠️ 这是新手最容易踩坑的地方。写函数时要清楚:参数是 struct 还是 class?想不想让函数修改调用方的数据?struct 默认不会,class 默认会。


五、字段可变性:let 与 var

第 2 课学过 let(不可变)和 var(可变)。它们用在字段上时,行为和值/引用类型交织在一起,有四种组合,逐一理清。

5.1 struct + let 字段

struct ImmutablePoint {
    let x: Int64
    let y: Int64
    init(x: Int64, y: Int64) { this.x = x; this.y = y }
}

let 字段只能在 init 里赋值一次,之后永远不能改。整个 struct 是"只读的值"。

5.2 struct + var 字段(实例必须是 var)

struct MutablePoint {
    var x: Int64
    var y: Int64
    init(x: Int64, y: Int64) { this.x = x; this.y = y }
}

main(): Int64 {
    let p = MutablePoint(1, 2)
    // p.x = 100   // ❌ 编译错误:p 是 let
    var q = MutablePoint(1, 2)
    q.x = 100       // ✅ q 是 var,允许
    return 0
}

🚫 let 绑定的 struct,其 var 字段也不能改。 因为 struct 是值类型,let 意味着"整个值不可变",连带所有字段一起冻住。想改 struct 字段,实例变量必须用 var。这和很多语言(C#、Go)不同——那些语言 readonly 的 struct 仍可改 var 字段,仓颉更严格。

5.3 class + let 字段

class Person {
    let name: String     // 名字一生不变
    var age: Int64       // 年龄会增长
    init(name: String, age: Int64) { this.name = name; this.age = age }
}

let 字段同样只能在 init 里赋值一次。

5.4 class + var 字段(实例可以是 let)

main(): Int64 {
    let a = Person("Alice", 30)
    // a.name = "Bob"   // ❌ name 是 let 字段,不能改
    a.age = 31           // ✅ age 是 var 字段,可以改
    return 0
}

📌 let 绑定的 class 实例,其 var 字段仍可改。 因为 let a 只约束"a 这个引用不能改指向"(不能让 a 去指另一个对象),但对象内部的 var 字段该改还能改。这是值类型和引用类型在可变性上的根本差异。

四组组合一句话记忆:

  • struct + let 实例:整体只读,啥都不能改
  • struct + var 实例var 字段可改,let 字段不能改
  • class + let 实例:引用固定,var 字段可改
  • class + var 实例:引用可换,var 字段可改

六、方法与 this

光有数据不够,还需要"操作数据的行为"。把函数放进 struct/class 里,就叫方法(method)。

6.1 class 的方法

class Person {
    let name: String
    var age: Int64
    init(name: String, age: Int64) { this.name = name; this.age = age }

    public func sayHi(): String {
        return "Hi, I'm ${this.name}, age ${this.age}"
    }

    public func growOlder() {
        this.age += 1
    }
}

main(): Int64 {
    let a = Person("Alice", 30)
    println(a.sayHi())
    a.growOlder()
    println(a.sayHi())
    return 0
}
Hi, I'm Alice, age 30
Hi, I'm Alice, age 31
  • 方法用 public func 定义(public 让外部能调用,第 20 课细讲可见性);
  • 方法里用 this 访问字段;
  • class 的方法可以直接修改 var 字段growOlder()this.age += 1 完全合法。

6.2 struct 的方法:不能修改 this

struct Point {
    var x: Int64
    var y: Int64
    init(x: Int64, y: Int64) { this.x = x; this.y = y }

    public func toString(): String {
        return "(${this.x}, ${this.y})"
    }

    public func moveBy(dx: Int64, dy: Int64): Point {
        return Point(this.x + dx, this.y + dy)   // ✅ 返回新值
    }

    // public func moveByMut(dx: Int64, dy: Int64) {
    //     this.x += dx   // ❌ 报错:cannot be modified in immutable function
    // }
}

🚫 struct 的方法不能修改 this 的字段。 仓颉没有 Swift 那种 mutating 关键字。struct 方法只能读取 this,想"改变"就返回一个新的 struct。这是值类型的设计哲学:值不应该被"原地改动",而是"产生一个新值"。

调用方式:

main(): Int64 {
    let p = Point(1, 2)
    let p2 = p.moveBy(10, 20)
    println("${p.toString()} -> ${p2.toString()}")
    return 0
}
(1, 2) -> (11, 22)

p 本身没变,moveBy 返回了一个新的 Point


七、struct 还是 class:选型决策

这是每写一个新类型都要问自己的问题。一个简单的判断框架:

该用 struct 的情况

  1. 它本质是"一个值":坐标点、颜色(RGB)、日期、金额、尺寸、分数……这些东西的"身份"就是它的值,两个值相等就算"同一个"。
  2. 希望赋值/传参时自动复制:不担心副作用,改副本不影响原件。
  3. 不需要继承:struct 不能继承,行为靠组合(第 17 课)。
  4. 数据小、生命周期短:放在栈上,分配快。

该用 class 的情况

  1. 它有"身份":学生(有学号)、订单(有订单号)、账户(有账号)……即使两个学生所有字段都一样,他们也是两个人。
  2. 需要共享和修改:一个对象被多处引用,改一处处处生效(比如购物车里的商品对象)。
  3. 需要继承:class 支持继承(第 17 课)。
  4. 有丰富的行为:方法需要修改自身状态(struct 做不到原地修改)。

一个经验法则

问自己:“把这个东西赋值给另一个变量后,我改新变量,旧变量应不应该跟着变?”

  • 不应该 → struct(值语义)
  • 应该 → class(引用语义)

坐标点:改副本不该影响原点 → struct。
学生:改了"另一个名字指向的学生",原学生也该变 → class。


八、CIDE 实操:图书与书架

8.1 编写程序

综合运用:struct 表示图书(值),class 表示书架(有身份、可变),演示值/引用差异、let/var 字段、class 方法修改状态、struct 方法返回新值。

package BookShelf

// struct:图书是"值",复制一本不影响原书
struct Book {
    let title: String
    let author: String
    var price: Float64

    init(title: String, author: String, price: Float64) {
        this.title = title
        this.author = author
        this.price = price
    }

    public func info(): String {
        return "${this.title}(${this.author})¥${this.price}"
    }

    // struct 方法不能改 this,返回新对象表示"打折后的书"
    public func discounted(rate: Float64): Book {
        return Book(this.title, this.author, this.price * rate)
    }
}

// class:书架是"对象",所有引用共享同一份库存
class Shelf {
    var books: ArrayList<Book>
    var capacity: Int64

    init(capacity: Int64) {
        this.books = ArrayList<Book>()
        this.capacity = capacity
    }

    public func add(book: Book): Bool {
        if (this.books.size >= this.capacity) {
            return false
        }
        this.books.add(book)
        return true
    }

    public func count(): Int64 {
        return this.books.size
    }

    public func list(): String {
        var lines = ArrayList<String>()
        for (b in this.books) {
            lines.add(b.info())
        }
        // ArrayList 没有 joinToString,用 fold 手动拼接
        return lines.iterator().fold("") { acc, s =>
            if (acc.isEmpty()) { s } else { "${acc}\n${s}" }
        }
    }
}

main(): Int64 {
    // —— 值类型:图书赋值即复制 ——
    let b1 = Book("仓颉语言入门", "张三", 59.0)
    var b2 = b1
    b2.price = 0.0            // 把"副本"价格改 0
    println("原书:${b1.info()}")
    println("副本:${b2.info()}")

    // struct 方法返回新值
    let b3 = b1.discounted(0.8)
    println("八折:${b3.info()}")

    // —— 引用类型:书架共享 ——
    let shelf = Shelf(3)
    let ref = shelf              // ref 和 shelf 指向同一个书架

    shelf.add(b1)
    shelf.add(b3)
    println("shelf 上有 ${shelf.count()} 本书")
    println("ref 上有 ${ref.count()} 本书")   // 同一个书架,也是 2 本

    let ok = shelf.add(b2)
    println("加第三本:${ok}(容量 3)")
    println("再塞一本:${shelf.add(b1)}")   // 满了,返回 false

    println("--- 书架清单 ---")
    println(shelf.list())

    return 0
}

8.2 运行与验证

原书:仓颉语言入门(张三)¥59.0
副本:仓颉语言入门(张三)¥0.0
八折:仓颉语言入门(张三)¥47.2
shelf 上有 2 本书
ref 上有 2 本书
加第三本:true(容量 3)
再塞一本:false
--- 书架清单 ---
仓颉语言入门(张三)¥59.0
仓颉语言入门(张三)¥47.2
仓颉语言入门(张三)¥0.0

关键点解读:

  • b2 = b1 复制了一本书,改 b2.price 不影响 b1.price(值类型)。
  • discounted 不改原书,返回一本新的打折书——struct 方法的标准写法。
  • let ref = shelfrefshelf 指向同一个 Shelf 对象,所以 shelf.addref.count() 也是 2(引用类型)。
  • add 在 class 里直接修改 this.books,合法;如果 Shelf 是 struct,这行就会报 “cannot be modified in immutable function”。
  • 容量满时 add 返回 false,调用方据此判断。

可以自己动手改:

  • var b2 = b1 改成 let b2 = b1,观察 b2.price = 0.0 报什么错;
  • class Shelf 改成 struct Shelf,看 add 方法里 this.books.add(book) 报什么错(struct 方法不能改 this);
  • Book 加一个 var edition: Int64 字段,在 main 里验证 let 绑定的 Book 不能改 edition、var 绑定的可以。

8.3 用调试器观察值与引用

  1. b2.price = 0.0 行打断点,Shift + F9 启动调试;
  2. 断住后展开 Variables 面板的 b1b2,确认它们是两个独立的对象,price 分别是 59.0 和 0.0;
  3. shelf.add(b1) 行打断点,观察 shelfref 指向同一个 Shelf 实例(地址相同);
  4. 单步执行 shelf.add(b3) 后,再看 ref.count()——确认 ref 没主动操作,数据却变了(共享引用)。

调试结束点停止按钮。


九、常见问题 FAQ

Q1:struct 和 class 语法几乎一样,我怎么记得住区别?
别记语法,记语义:struct 是值(复制),class 是引用(共享)。写代码时先想"赋值后改副本会不会影响原件",再决定用哪个。语法只是关键字不同。

Q2:为什么 let p = Point(...)p.x = 100 报错,let a = Person(...)a.age = 30 却可以?
因为 struct 是值类型,let 冻住的是整个值(所有字段);class 是引用类型,let 只冻住引用(不能换对象),对象内部的 var 字段不受影响。一句话:struct 的 let 是"值不可变",class 的 let 是"引用不可变"

Q3:struct 的方法真的不能修改字段吗?那我想给 struct 加个 “自增” 方法怎么办?
仓颉 1.1.3 确实不能。标准做法是返回新值:func incremented(): Counter { return Counter(this.value + 1) },调用时 let c2 = c.incremented()。这是值类型的"函数式"风格,虽然啰嗦但杜绝了副作用。如果确实需要频繁原地修改,考虑用 class。

Q4:仓颉有 mutating 关键字吗(像 Swift 那样)?
没有。写 public mutating func ... 会报 expected declaration, found 'mutating'。struct 方法一律不能改 this。

Q5:struct 和 class 哪个性能更好?
小的 struct 分配在栈上,创建和销毁极快,且没有 GC 压力。class 在堆上分配,需要 GC 回收。简单值用 struct 更高效。但大的 struct 复制成本高,频繁传递时反而不如 class。一般来说,字段少、值语义用 struct;字段多、有行为用 class。

Q6:为什么第 14 课的迭代器用 class 而不是 struct?
迭代器需要"记住游标位置"并在每次 next() 时推进——这要求方法能修改自身状态。struct 做不到(方法不能改 this),所以必须用 class。这也是判断用 struct 还是 class 的一个实用标准:方法需不需要改自己的状态?需要就用 class。

Q7:init 构造函数必须写吗?
如果字段都有默认值(var x: Int64 = 0),可以不写 init,编译器提供无参构造。如果有字段没有默认值,就必须写 init 并在里面初始化所有 let 和无默认值的 var 字段。第 16 课会系统讲构造函数的各种规则。

Q8:可以省略 this 吗?
字段名和参数名不同时可以省略(init(name: String) { this.name = name } 里,this.name 指字段,name 指参数,this 不能省,否则分不清)。如果参数名换一个(比如 init(n: String) { name = n }),就可以省略 this。但工程上建议字段赋值永远写 this,清晰无歧义。

Q9:一个文件里可以定义多个 struct/class 吗?
可以。同一个包下想定义多少个都行。本课的实操例子就同时定义了 BookShelf

Q10:struct/class 可以嵌套定义吗?
仓颉 1.1.3 不支持在 struct/class 内部再定义 struct/class。所有类型都定义在包级别(文件顶层)。


十、课后练习

  1. 定义一个 struct Rectangle,有 widthheight 两个 var Float64 字段,init 构造函数,以及一个 area(): Float64 方法返回面积。创建 Rectangle(3.0, 4.0) 并打印面积。
  2. 给第 1 题的 Rectangle 加一个 scaled(factor: Float64): Rectangle 方法,返回宽高都乘以 factor 的新矩形(注意:struct 方法不能改 this)。验证 r.scaled(2.0) 后原矩形 r 不变。
  3. 定义一个 class BankAccount,有 let owner: Stringvar balance: Float64 字段,以及 deposit(amount)withdraw(amount): Bool 方法(余额不足返回 false)。创建账户后存款、取款,验证余额变化。
  4. 写一个函数 transfer(from: BankAccount, to: BankAccount, amount: Float64): Bool,从 from 取款并存入 to(取款失败则不存款)。验证转账后两个账户余额正确,理解 class 传参是共享引用。
  5. 综合:定义 struct Colorrgb 三个 var Int64 字段,0-255)和 class Canvas(持有一个 ArrayList<Color> 调色板)。给 Colordarker(): Color 返回一个颜色减半的新颜色;给 Canvasadd(c: Color)count()。在 main 中创建调色板,加几种颜色,用 darker() 生成暗色版本加入,最后打印调色板大小。

下节预告

这节课把 struct/class 的"骨架"搭好了:定义、字段、构造、方法、值与引用的差异。但构造函数和方法还有很多细节没展开——多构造函数、默认参数、属性 getter/setter、方法的可见性与静态成员。第 16 课 构造函数、属性与方法 把这些一次讲透,让你的类型真正"好用"。我们下节课见!


系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.1.3)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入(例如新版本为 struct 方法增加了修改自身状态的能力、放宽了 let struct 的字段修改限制等),以你本地版本为准,欢迎评论区交流。


💬 遇到问题?扫码联系作者

跟着课程练习时,如果在 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 云服务平台

更多推荐