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

【仓颉语言入门 · 第16课】构造函数、属性与方法

第 15 课我们把 struct/class 的骨架搭好了,知道了值类型和引用类型的根本差异。但这还不够——写真实项目时,每个类型都要回答这些问题:构造函数有几种?字段的默认值怎么给?属性有没有 getter/setter?方法要不要被外部调用?静态成员怎么共享?本课把第 15 课埋下的所有伏笔一次性讲透。

本文所有代码与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行。

⚠️ 勘误:第 15 课说"struct 的方法不能修改 this,仓颉没有 mutating 关键字"——这是不准确的。仓颉一直用 mut 关键字实现这个能力(public mut func),从早期版本就支持,本课第四节会讲清楚。第 15 课的错误结论会在本课同步更正。


目录(系列导航)

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

一、构造函数:init 与重载

第 15 课写过一个最基本的构造函数:

struct Point {
    var x: Int64
    var y: Int64

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

现实项目中,一个类型往往有多种初始化方式。仓颉的解决方案是构造函数重载——一个类型里可以定义多个 init,只要参数列表不同就行。

1.1 重载构造函数

class Point {
    var x: Int64
    var y: Int64

    // 无参构造:默认原点
    init() {
        this.x = 0
        this.y = 0
    }

    // 单参数构造:对称点
    init(v: Int64) {
        this.x = v
        this.y = v
    }

    // 双参数构造:任意坐标
    init(x: Int64, y: Int64) {
        this.x = x
        this.y = y
    }
}

main(): Int64 {
    let p1 = Point()       // (0, 0)
    let p2 = Point(5)      // (5, 5)
    let p3 = Point(3, 4)   // (3, 4)
    println("p1(${p1.x}, ${p1.y}), p2(${p2.x}, ${p2.y}), p3(${p3.x}, ${p3.y})")
    return 0
}
p1(0, 0), p2(5, 5), p3(3, 4)

编译器根据你传几个参数、什么类型,自动匹配到对应的 init。和函数重载(第 11 课)的规则一样。

1.2 构造函数委托:this()

多个构造函数之间可能有重复逻辑。仓颉支持在一个构造函数里调用另一个构造函数,用 this(参数):

class Point {
    var x: Int64
    var y: Int64

    init() {
        this(0, 0)   // 委托给双参数构造
    }

    init(v: Int64) {
        this(v, v)   // 委托给双参数构造
    }

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

📌 this() 必须出现在构造函数体的第一行,且只能调一次。不能搞循环委托(A 调 B、B 调 A),编译器会检查。

1.3 默认参数值

另一种减少重载的方式是给参数默认值。仓颉的默认参数必须用命名参数形式——参数名前加 !:

class Point {
    var x: Int64
    var y: Int64

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

main(): Int64 {
    let p1 = Point()               // (0, 0)
    let p2 = Point(x: 5)           // (5, 0) ——只传 x,y 取默认值
    let p3 = Point(x: 3, y: 4)     // (3, 4)
    return 0
}
0,0
5,0
3,4

参数有默认值后,调用时可以省略。注意:默认参数必须放在参数列表末尾(和函数默认参数规则一致)。

1.4 字段默认值 + 无参构造

如果所有字段都有默认值,可以不写 init,编译器自动生成无参构造函数:

class Config {
    var debug: Bool = false
    var port: Int64 = 8080
    var host: String = "localhost"
}

main(): Int64 {
    let cfg = Config()    // 所有字段取默认值
    println("${cfg.host}:${cfg.port}, debug=${cfg.debug}")
    return 0
}
localhost:8080, debug=false

⚠️ 如果任何一个字段没有默认值,就必须显式写 init,否则编译报错:'x' is not initialized in the constructor。


二、主构造函数:最简写法

仓颉还有一种更简洁的构造函数形式——主构造函数(primary constructor),和普通 init 的区别在于:

  1. 函数名和类型名相同(不是 init)
  2. 参数列表里可以直接用 let / var 声明成员变量形参,它既是构造参数,也是字段声明

2.1 class 的主构造函数

class Person {
    // 主构造函数:Person 就是函数名
    public Person(let name: String, var age: Int64) {}
}

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

一行 public Person(let name: String, var age: Int64) {} 同时完成了三件事:

  • 声明了一个 let name: String 字段(不可变)
  • 声明了一个 var age: Int64 字段(可变)
  • 定义了一个 Person(name, age) 构造函数,自动把参数赋给字段

2.2 struct 的主构造函数

struct 同样支持:

import std.math.*

struct Vec2 {
    public Vec2(let x: Float64, let y: Float64) {}

    public func length(): Float64 {
        return sqrt(this.x * this.x + this.y * this.y)
    }
}

main(): Int64 {
    let v = Vec2(3.0, 4.0)
    println("length = ${v.length()}")
    return 0
}
length = 5.000000

2.3 主构造函数 + 额外逻辑

如果主构造函数里需要额外逻辑,直接在大括号里写:

class Rectangle {
    public Rectangle(let width: Float64, let height: Float64) {
        if (width <= 0.0 || height <= 0.0) {
            throw Exception("边长必须为正数")
        }
    }
}

📌 一个类型里最多只能有一个主构造函数(否则报重定义),但可以和多个普通 init 共存。例如:主构造函数负责常规初始化,init() 负责无参默认值。


三、属性 prop:封装字段的艺术

前面我们一直在直接暴露字段:p.name、p.age。实际项目中,字段往往不能随便改——比如年龄不能是负数、余额不能为负。仓颉用 属性(prop) 解决这个问题。

3.1 只读属性(getter only)

class Circle {
    private let radius: Float64

    init(radius: Float64) {
        this.radius = radius
    }

    public prop area: Float64 {
        get() {
            return 3.14159 * this.radius * this.radius
        }
    }
}

main(): Int64 {
    let c = Circle(5.0)
    println("area = ${c.area}")   // 像字段一样访问,但只读
    return 0
}
area = 78.539750
  • private let radius:真正的字段被隐藏
  • public prop area:对外暴露一个"伪字段",访问时调用 get() 计算
  • 外部只能读 c.area,不能写

3.2 读写属性(getter + setter)

需要外部可写的属性,用 mut prop:

class Account {
    private var balanceValue: Float64

    init() {
        this.balanceValue = 0.0
    }

    public mut prop balance: Float64 {
        get() { return this.balanceValue }
        set(v) {
            if (v >= 0.0) {
                this.balanceValue = v
            } else {
                throw Exception("余额不能为负数")
            }
        }
    }
}

main(): Int64 {
    let a = Account()
    a.balance = 100.0          // 调用 setter
    println("余额: ${a.balance}") // 调用 getter
    // a.balance = -50.0       // ❌ 抛出异常:余额不能为负数
    return 0
}
余额: 100.000000

关键点:

  • public mut prop 表示"可变的属性"(外部可读写)
  • get() 和 set(v) 里的 v 是传入的值
  • setter 里可以加任意校验逻辑——这就是封装的意义

3.3 prop 不需要 backing field

属性的底层字段(如 balanceValue)不是必须的。如果属性本身就是计算出来的,可以不存:

class Temperature {
    private var celsius: Float64

    init(celsius: Float64) {
        this.celsius = celsius
    }

    public prop celsiusValue: Float64 {
        get() { return this.celsius }
    }

    public mut prop fahrenheit: Float64 {
        get() { return this.celsius * 1.8 + 32.0 }
        set(f) { this.celsius = (f - 32.0) / 1.8 }
    }
}

main(): Int64 {
    let t = Temperature(25.0)
    println("华氏度: ${t.fahrenheit}")  // 77.0
    t.fahrenheit = 98.6
    println("摄氏度: ${t.celsiusValue}") // 37.0
    return 0
}
华氏度: 77.000000
摄氏度: 37.000000

fahrenheit 没有自己的存储空间,每次访问都实时计算。写入时反向换算存到 celsius 里。


四、mut 关键字:struct 方法改自己的正确姿势

第 15 课我们说过:struct 的普通方法不能修改 this 的字段,想改变就得返回新值。这个结论本身没错——但它只说了"普通方法",漏掉了仓颉真正的解决方案。

4.1 mut 方法:原地修改 struct

仓颉用 mut 关键字修饰实例方法,允许它原地修改 struct 的字段:

struct Point {
    var x: Int64
    var y: Int64

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

    public mut func moveBy(dx: Int64, dy: Int64) {
        this.x += dx
        this.y += dy
    }

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

main(): Int64 {
    var p = Point(1, 2)
    p.moveBy(10, 20)      // ✅ 原地修改
    println(p.toString())
    return 0
}
(11, 22)

public mut func moveBy(...):加了 mut 后,方法里可以改 this 的字段。

4.2 调用 mut 方法的规则

    let p = Point(1, 2)
    // p.moveBy(10, 20)   // ❌ 编译错误:let 绑定的实例不能调用 mut 方法

    var q = Point(1, 2)
    q.moveBy(10, 20)      // ✅ var 绑定才能调用

📌 只有 var 绑定的实例才能调用 mut 方法。let 绑定意味着"值不可变",mut 方法会破坏这个承诺,编译器直接拒绝。这和 class 不同:class 的 let 绑定只是引用不可变,对象内部该改还能改(第 15 课)。

4.3 class 方法不需要 mut

class 的方法默认就能改字段(引用类型本来就能改),不允许加 mut:

class Person {
    var age: Int64 = 0

    public func growOlder() {
        this.age += 1       // ✅ 直接改,不需要也不能加 mut
    }
}

mut 是值类型专属的修饰符(struct、interface、struct 扩展),解决 struct "普通方法不能改 this" 的问题。写 class 里会报编译错误。


五、可见性控制:谁能访问什么

仓颉有四种访问修饰符,控制字段、方法、构造函数的可见范围:

修饰符 可见范围
public 任何地方都能访问(默认如果不写,包内可见)
protected 当前类及其子类(第 17 课讲继承)
private 仅当前类/struct 内部
internal 当前包及子包内可见

5.1 private 字段 + public 方法

最常见的封装模式:

class BankAccount {
    private let owner: String
    private var balanceValue: Float64

    init(owner: String) {
        this.owner = owner
        this.balanceValue = 0.0
    }

    public func deposit(amount: Float64) {
        if (amount > 0.0) {
            this.balanceValue += amount
        }
    }

    public func withdraw(amount: Float64): Bool {
        if (amount > 0.0 && amount <= this.balanceValue) {
            this.balanceValue -= amount
            return true
        }
        return false
    }

    public prop balance: Float64 {
        get() { return this.balanceValue }
    }
}

main(): Int64 {
    let acc = BankAccount("Alice")
    acc.deposit(100.0)
    println("余额: ${acc.balance}")
    println("取款 30: ${acc.withdraw(30.0)}")
    println("余额: ${acc.balance}")
    // acc.balanceValue = 999.0  // ❌ private,外部不可访问
    return 0
}
余额: 100.000000
取款 30: true
余额: 70.000000

外部只能通过 deposit/withdraw 操作余额,不能直接改 balanceValue。这就是面向对象的封装。

5.2 可见性修饰构造函数

构造函数也可以限制可见性:

class Singleton {
    private init() {}   // 私有构造,外部无法 new

    public static func getInstance(): Singleton {
        return Singleton()   // 类内部可以调用 private init
    }
}

私有构造函数 + 静态工厂方法 = 单例模式的雏形(完整实现需要静态字段持有实例,第 17 课细讲)。


六、静态成员:属于类型,不属于实例

有些数据和函数不属于某个具体对象,而是属于整个类型。比如:

  • 所有圆共享一个 pi 常量
  • 统计创建了多少个对象

仓颉用 static 修饰符实现。

6.1 静态字段

class Circle {
    static let pi: Float64 = 3.14159
    private let radius: Float64

    init(radius: Float64) {
        this.radius = radius
    }

    public func area(): Float64 {
        return Circle.pi * this.radius * this.radius   // 通过类型名访问
    }
}

main(): Int64 {
    println("pi = ${Circle.pi}")   // 不创建实例也能访问
    let c = Circle(5.0)
    println("area = ${c.area()}")
    return 0
}
pi = 3.141590
area = 78.539750

📌 static 字段必须有初值(或在静态初始化器里初始化),不能依赖实例。

6.2 静态初始化器

如果静态字段的初始化需要复杂逻辑,用 static init:

class Config {
    static let appName: String
    static let version: String

    static init() {
        appName = "CIDE"
        version = "1.2.0"
    }
}

main(): Int64 {
    println("${Config.appName} v${Config.version}")
    return 0
}
CIDE v1.2.0
  • static init() 没有参数、没有返回值
  • 一个类型里最多只能有一个 static init
  • 它会在类型首次使用时自动执行,且只执行一次
  • 必须初始化所有未赋初值的 static let 字段,否则编译报错

6.3 静态方法

class Math {
    public static func max(a: Int64, b: Int64): Int64 {
        return if (a > b) { a } else { b }
    }

    public static func square(x: Int64): Int64 {
        return x * x
    }
}

main(): Int64 {
    println("max(3, 7) = ${Math.max(3, 7)}")
    println("square(5) = ${Math.square(5)}")
    return 0
}
max(3, 7) = 7
square(5) = 25

静态方法里不能访问实例字段(没有 this),只能访问其他静态成员。


七、CIDE 实操:BankAccount 综合练习

7.1 编写程序

综合运用本课知识:主构造函数、属性、可见性、静态成员、mut 方法。

package BankDemo

class BankAccount {
    // 静态计数器
    static var count: Int64 = 0

    private let id: Int64
    private var balanceValue: Float64

    // 主构造函数:let owner 同时声明了 public 只读字段
    public BankAccount(let owner: String) {
        this.balanceValue = 0.0
        this.id = BankAccount.count
        BankAccount.count += 1
    }

    public func deposit(amount: Float64): Bool {
        if (amount <= 0.0) { return false }
        this.balanceValue += amount
        return true
    }

    public func withdraw(amount: Float64): Bool {
        if (amount <= 0.0 || amount > this.balanceValue) { return false }
        this.balanceValue -= amount
        return true
    }

    public prop balance: Float64 {
        get() { return this.balanceValue }
    }

    public prop info: String {
        get() { return "[${this.id}] ${this.owner}: ¥${this.balance}" }
    }

    public static func getTotalAccounts(): Int64 {
        return BankAccount.count
    }
}

main(): Int64 {
    let a1 = BankAccount("Alice")
    let a2 = BankAccount("Bob")

    a1.deposit(1000.0)
    a1.withdraw(200.0)
    a2.deposit(500.0)

    println(a1.info)
    println(a2.info)
    println("总开户数: ${BankAccount.getTotalAccounts()}")

    return 0
}

7.2 运行与验证

[0] Alice: ¥800.000000
[1] Bob: ¥500.000000
总开户数: 2

关键点解读:

  • BankAccount(let owner: String) 主构造函数自动创建 let owner 字段
  • static var count 追踪开户数量,所有实例共享
  • private 字段只能通过 deposit/withdraw 修改,外部无法绕过
  • balance 和 info 是只读属性,封装了内部状态
  • getTotalAccounts() 是静态方法,不依赖任何实例

7.3 调试观察

  1. 在 BankAccount.count += 1 行打断点,观察每次创建账户时 count 的变化;
  2. 在 a1.deposit(1000.0) 行打断点,确认 a1.balanceValue 从 0 变为 1000;
  3. 在 println(a1.info) 行打断点,展开 a1 的字段面板,确认 id/owner/balanceValue 的值。

八、常见问题 FAQ

Q1:主构造函数和普通 init 可以混用吗?
可以。一个类型里最多一个主构造函数,但可以有多个普通 init。例如主构造函数处理常规参数,init() 提供无参默认值。

Q2:prop 和直接暴露字段有什么区别?
字段是存储空间,prop 是访问通道。prop 可以在 getter/setter 里加校验、日志、缓存等逻辑;字段只能存值。工程上建议:对外暴露的全部用 prop,字段全部 private。

Q3:mut prop 和 public var 字段有什么区别?
public var 字段外部可以直接读写,没有任何校验;mut prop 外部读写都走 getter/setter,可以在 setter 里加逻辑。功能上 mut prop 更强大,但 public var 写法更简单。

Q4:struct 的 mut 方法和 class 的方法有什么区别?
struct 的 mut 方法只能在 var 绑定的实例上调用(因为会改值);class 的方法不需要 mut,因为 class 是引用类型,let 绑定只约束引用不变,对象内部该改还能改。

Q5:static init 什么时候执行?
在类型首次被使用时(创建实例或访问静态成员)自动执行,且只执行一次。不要在里面写依赖运行时条件的复杂逻辑。

Q6:private init() 有什么用?
限制外部直接创建实例。配合静态工厂方法(如 getInstance())可以实现单例模式、对象池等设计模式。

Q7:prop 可以没有 backing field 吗?
可以。如果 prop 的值完全是计算出来的(如 fahrenheit 换算),不需要额外的存储字段。但如果 prop 的 setter 需要存值,就得有 backing field。

Q8:第 15 课说 struct 方法不能改 this,这节课又说 mut 方法可以,到底哪个对?
这节课对。mut 从仓颉早期版本就存在(不是 1.2.0 新增),第 15 课的结论有误——当时误以为仓颉没有 mutating 关键字就没有原地修改能力,实际上正确的关键字是 mut。第 15 课的 FAQ Q3、Q4 已同步更正。


九、课后练习

  1. 给 Point 加一个 public mut prop distanceFromOrigin: Float64,它的 getter 计算到原点的距离,setter 接受一个新距离,把点移到同角度的新位置(提示:保持角度不变,只改半径)。
  2. 写一个 class Counter,有 private var value: Int64,提供 increment()、decrement() 和只读属性 count。再加一个 static var totalIncrements: Int64 统计所有 Counter 实例的 increment 总次数。
  3. 用主构造函数简化第 15 课练习 1 的 Rectangle,写成 public Rectangle(let width: Float64, let height: Float64) {},提供 area() 方法和 prop perimeter: Float64(只读)。
  4. 把第 15 课的 Book struct 加一个 mut func applyDiscount(rate: Float64),原地修改 price,不再需要返回新对象。注意调用时实例必须用 var 绑定。
  5. 综合:实现一个 class TemperatureSensor,有 private var celsius: Float64,提供 mut prop fahrenheit(getter/setter 双向换算)、mut func record(t: Float64) 记录温度,以及 static var recordCount: Int64 统计记录次数。在 main 中创建传感器,记录几次温度,打印华氏度。

下节预告

struct 和 class 都有了,但类之间怎么复用代码?怎么定义"能飞的""能游的"这类抽象能力?第 17 课 接口 interface 与实现 讲清仓颉的接口定义、实现、继承和多态,这是面向对象设计的核心。


系列说明:本系列基于 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
posted @ 2026-09-25 01:17  upala  阅读(3)  评论(0)    收藏  举报