6815 字
34 分钟
仓颉文档阅读-开发指南II: 基本概念(I)
NOTE

阅读文档版本:

语言规约 Cangjie-0.53.18-Spec

具体开发指南 Cangjie-LTS-1.0.3

在阅读 了解仓颉的语言规约时, 难免会涉及到一些仓颉的示例代码, 但 我们对仓颉并不熟悉, 所以可以用 仓颉在线体验 快速验证

有条件当然可以直接 配置Canjie-SDK

WARNING

博主在此之前, 基本只接触过C/C++语言, 对大多现代语言都没有了解, 所以在阅读过程中遇到相似的概念, 难免会与C/C++中的相似概念作类比, 见谅

WARNING

在阅读仓颉编程语言的开发指南之前, 已经大概阅读了一遍 仓颉编程语言的语言规约, 已经对仓颉编程语言有了一个大概的了解

所以在阅读开发指南时, 不会对类似: 类、函数、结构体、接口等解释起来较为复杂名称 做出解释

此样式内容, 表示文档原文内容

基本概念#

标识符#

在仓颉编程语言中, 开发者可以给一些程序元素命名, 这些名字被称为”标识符”

学习标识符之前, 需要了解一些Unicode字符集概念

Unicode标准中, XID_StartXID_Continue属性用于标记可以作为Unicode标识符(Identifier)的起始字符和后续字符, 其详细定义请参见Unicode标准文档

其中, XID_Start包含中文和英文等字符, XID_Continue包含中文、英文和阿拉伯数字等字符

仓颉语言使用Unicode标准15.0.0

标识符, 如果你接触过任何一个编程语言, 一定了解什么是标识符

比如: 变量名, 对象名, 函数名, 结构体名, 类名, 宏定义名等等, 由开发者 为标识某个元素 而起得名字, 就是标识符

如果是C++语言, C++11之后的标准中也使用Unicode标准作为标识符命名可用的字符

UnicodeXID_Start可作为标识符的起始字符, 比如:

  1. ASCII字符

    A, B, C, ...

    a, b, c, ...

  2. 带重音的拉丁字母

    á, ñ, ö, ü, ç, ...

  3. 希腊字母

    α (U+03B1), β (U+03B2), Γ (U+0393), ...

  4. 中文汉字

    中 (U+4E2D), 文 (U+6587), 变 (U+53D8), ...

  5. ...

UnicodeXID_Continue可作为标识符的后续字符, 比如:

  1. 数字

    0, 1, ..., 9

    全角数字: 0 (U+FF10), 1 (U+FF11)

    阿拉伯-印度数字: ٠ (U+0660), ١ (U+0661)

  2. 下划线

    _ (U+005F)

  3. 连接符/连字符类(部分)

    ‿ (U+203F, undertie)

    ⁀ (U+2040, character tie)

    ⁔ (U+2054, inverted undertie)

    但注意: 普通连字符- (U+002D)不是XID_Continue

  4. 所有XID_Start字符

  5. ...

仓颉编程语言的标识符分为普通标识符原始标识符两类, 它们遵从不同的命名规则

普通标识符不能和仓颉关键字相同, 其取自以下两类字符序列:

  • XID_Start字符开头, 后接任意长度的XID_Continue字符

  • 由一个_开头, 后接至少一个XID_Continue字符

虽然_XID_Continue标识符, 但也可以作为标识符的起始字符

按照上面的简单介绍, 数字和一些连接符是无法作为标识符的起始字符的

仓颉把所有标识符识别为Normalization Form C (NFC)后的形式

两个标识符如果在NFC后相等, 则被认为是相同的标识符

例如, 以下每行字符串都是合法的普通标识符:

abc
_abc
abc_
a1b2c3
a_b_c
a1_b2_c3
仓颉
__こんにちは

以下每行字符串都是不合法的普通标识符:

ab&c // Error, & 不是 XID_Continue 字符
3abc // Error, 阿拉伯数字不是 XID_Start 字符, 因此, 数字不能作为起始字符
_ // Error, _ 后至少需要有一个 XID_Continue 字符
while // Error, while 是仓颉关键字, 普通标识符不能使用仓颉关键字

原始标识符是在普通标识符或仓颉关键字的首尾加上一对反引号, 主要用于将仓颉关键字作为标识符的场景

例如, 以下每行字符串都是合法的原始标识符:

`abc`
`_abc`
`a1b2c3`
`if`
`while`
`à֮̅̕b`

以下每行字符串, 由于反引号内的部分是不合法的普通标识符, 所以它们整体也是不合法的原始标识符:

`ab&c`
`3abc`

仓颉中, 以XID_StartXID_Continue组成的普通标识符, 普通标识符禁止与仓颉语言的关键字相同

仓颉中, 除普通标识符外, 还可以在普通标识符首尾两端加上一对’`’, 表示原始标识符, 原始标识符’`‘内的内容可以与仓颉关键字相同

程序结构#

通常, 开发者会在扩展名为.cj的文本文件中编写仓颉程序, 这些程序和文件也被称为源代码和源文件

在程序开发的最后阶段, 这些源代码将被编译为特定格式的二进制文件

扩展名, 一般是用来标识文件类型的, 比如: .txt是文本文件, .exeWindows平台上的可执行程序文件, .mdMarkdown文件等

不同的编程语言, 一般都会有自己的源代码文件扩展名, 比如:

  • C语言是.c, C++是.cpp\.cc\.cxx

  • Golang是.go

  • Java是.java

  • Python是.py

  • Rust是.rs

而仓颉就是.cj, 也就是说如果一个文件的扩展名是.cj, 不阅读内容的情况下, 你可以暂时将其看作仓颉语言的源代码文件

也可以说, 仓颉代码一般就写在.cj文件中

hello.cj
main() {
println("你好, 仓颉!")
}

在仓颉程序的顶层作用域中, 可以定义一系列的变量、函数和自定义类型(如structclassenuminterface等), 其中的变量和函数分别被称为全局变量全局函数

如果要将仓颉程序编译为可执行文件, 需要在顶层作用域中定义一个 main函数作为程序入口, 它可以有Array<String>类型的参数, 也可以没有参数, 它的返回值类型可以是整数类型或Unit类型

注意:

定义main函数时, 不需要写func修饰符

此外, 如果需要获取程序启动时的命令行参数, 可以声明和使用Array<String>类型参数

仓颉语言中, main函数被作为程序的入口, 这与C/C++一致

main函数不能使用func修饰符, 可以有Array<String>类型的参数, 返回值类型可以是Uint或任意整型

main(): Int64 {
println("hello world")
return 0
}
main(argv: Array<String>): Unit {
println("hello world")
}

没有main函数的代码, 无法编译出一个可执行文件

main函数需要被定义在顶层作用域, 关于顶层作用域: 一个文件, 什么内容也没有时, 写入的代码就在顶层作用域, 在{}内编写的代码就不在顶层作用域

上图中, 绿框没有选中的区域都是顶层作用域

WARNING

仓颉实际不允许单独的{}存在, 只能与相关表达式结合使用

例如, 在以下程序中, 在顶层作用域定义了全局变量a全局函数b, 还有自定义类型CDE, 以及作为程序入口的main函数

let a = 2023
func b() {}
struct C {}
class D {}
enum E { F | G }
main() {
println(a)
}

非顶层作用域中不能定义上述自定义类型, 但可以定义变量和函数, 称之为局部变量局部函数

特别地, 对于定义在自定义类型中的变量和函数, 称之为成员变量成员函数

注意:

enuminterface中仅支持定义成员函数, 不支持定义成员变量

例如, 在以下程序中, 在顶层作用域定义了全局函数a和自定义类型A, 在函数a中定义了局部变量b和局部函数c, 在自定义类型A中定义了成员变量b和成员函数c

func a() {
let b = 2023
func c() {
println(b)
}
c()
}
class A {
let b = 2024
public func c() {
println(b)
}
}
main() {
a()
A().c()
}

运行以上程序, 将输出:

Terminal window
2023
2024

结构体、类、接口和枚举, 在仓颉中只能定义在顶层作用域中, 这些自定义类型内部, 也可以定义变量和函数, 被称为成员变量和成员函数

变量和函数可以在顶层和非顶层作用域定义

main函数作为程序的入口, 程序运行时, 只会执行直接或间接写在main函数中的语句

变量#

在仓颉编程语言中, 一个变量由对应的变量名、数据(值)和若干属性构成, 开发者通过变量名访问变量对应的数据, 但访问操作需要遵从相关属性的约束(如数据类型、可变性和可见性等)

变量定义的具体形式为:

修饰符 变量名: 变量类型 = 初始值

其中修饰符用于设置变量的各类属性, 可以有一个或多个, 常用的修饰符包括:

  • 可变性修饰符: letvar, 分别对应不可变可变属性, 可变性决定了变量 被初始化后其值还能否改变, 仓颉变量也由此分为不可变变量和可变变量两类

  • const修饰符: const是一种特殊的变量修饰符

    它用于声明常量, 要求在声明时必须初始化, 一旦被赋值, 其值就不能被改变

    这与let修饰符类似, 都具有不可变的特性, 但const在使用上有更严格的限制

  • 可见性修饰符: privatepublic等, 影响全局变量和成员变量的可引用范围, 详见后续章节的相关介绍

  • 静态性修饰符: static, 影响成员变量的存储和引用方式, 详见后续章节的相关介绍

变量均支持赋值操作符(=), 与类型无关

let修饰的变量只能被赋值一次, 即初始化; var修饰的变量可以被多次赋值

变量, 是编程语言中为存储、使用某个类型的数据创建的一个元素

举一个最简单的例子:

var value: Int64 = 20

这个代码, 就创建了一个变量名为value的用于存储Int64类型数据的var可变变量, 并给定了一个初始值20

之后, 你就可以通过value使用存储在变量中的值, 目前是20, 你也可以通过value = 30以及类似的赋值操作, 修改value变量的值

当然, 如果是let value, 那就只能赋值一次

仓颉中, 你可以将变量看作一个贴有变量名贴纸的数据, 通过变量名就能访问数据

在定义仓颉变量时, 可变性修饰符是必要的, 在此基础上, 还可以根据需要添加其他修饰符

  • 变量名应是一个合法的仓颉标识符

  • 变量类型指定了变量所持有数据的类型

    当初始值具有明确类型时, 可以省略变量类型标注, 此时编译器可以自动推断出变量类型

  • 初始值是一个仓颉表达式, 用于初始化变量, 如果标注了变量类型, 需要保证初始值类型和变量类型一致

    定义全局变量或静态成员变量时, 必须指定初始值

    在定义局部变量或实例成员变量时, 可以省略初始值, 但需要标注变量类型, 同时要在此变量被引用前完成初始化, 否则编译会报错

例如, 下列程序定义了三个Int64类型的变量(分别为不可变变量a、可变变量bconst变量c)

随后修改了变量b的值, 同时将b的值赋值给a, 并调用println函数打印a, bc的值

main() {
let a: Int64
var b: Int64 = 14
const c: Int64 = 13
b = 12
a = b // 由 let 修饰的变量只能赋值一次, 即初始化
println("${a}, ${b}, ${c}")
}

编译运行此程序, 将输出:

Terminal window
12, 12, 13

在定义变量时, 必须要从varletconst中选择一个可变性修饰符进行修饰

分别对应可变变量(var)、不可变变量(let)以及常量变量(const)

变量名需要是一个合法的 标识符

一个变量一定被定义出来, 其类型就固定了, 就只能用来存储此类型数据, 如果是自定义类型 或许还可以存储其子类型

这与C/C++这种弱类型语言很不一样, C/C++中存在许多的隐式类型转换和强制类型转换, 而仓颉因为强类型所以少了很多类型转换

如果尝试修改不可变变量, 编译时会报错, 例如:

main() {
let pi: Float64 = 3.14159
pi = 2.71828 // Error, 无法赋值于不可变值
}

当初始值具有明确类型时, 可以省略变量类型标注, 例如:

main() {
let a: Int64 = 2023
let b = a
println("a - b = ${a - b}")
}

其中变量b的类型可以由其初值a的类型自动推断Int64, 所以此程序也可以被正常编译和运行, 将输出:

a - b = 0

不可变变量以及常量变量, 都是不可变性的变量, 无法多次赋值

在定义变量时, 如果直接进行初始化, 且初始化数值的类型是确定的, 那么定义变量类型可以省略声明, 由编译器自动推断

在定义局部变量时, 可以不进行初始化, 但一定要在变量被引用前赋予初值, 例如:

main() {
let text: String
text = "仓颉造字"
println(text)
}

编译运行此程序, 将输出:

仓颉造字

在定义全局变量和静态成员变量时必须初始化, 否则编译会报错, 例如:

let global: Int64 // Error, 在顶层作用域中的变量必须初始化
main(): Unit{
}
class Player {
static let score: Int32 // Error, 静态变量 'score' 在声明时需要初始化
}

需要注意的是, 当编译器无法判断某些场景是否一定会被初始化或无法判断是否重复初始化了不可变变量时, 会倾向于保守策略进行编译报错, 见如下示例:

func calc(a: Int32){
println(a)
return a * a
}
main() {
let a: String
if(calc(32) == 0){
a = "1"
}
a = "2" // Error, 无法赋值于不可变值
}

此外, 对于try-catch场景, 编译器会假设try总是全部被执行, 且总是抛异常, 从而进行相关报错, 见如下示例:

main() {
let a: String
try {
a = "1"
} catch (_) {
a = "2" // Error, 无法赋值于不可变值
}
}

局部letvar变量的在定义时, 可以不进行初始化, 但在首次使用之前必须要进行赋值, 否则编译报错

全局变量和静态变量, 必须在定义时进行初始化

且注意, 仓颉的try-catch中, 编译器静态语法解析时, 认为try块总是被全部执行, 且总是抛异常

意思是, 仓颉编译器总认为, try-catch的所有块中的代码全部都被执行, 即使try实际并不会抛异常, catch实际也并不会被执行

那么如果你尝试在两个块中, 各执行一次原本不允许多次执行的语句, 编译器也会认为这两句都一定会执行, 那么就出现了错误

const变量#

const变量是一种特殊的变量, 它以关键字const修饰, 定义在编译时完成求值, 并且在运行时不可改变的变量

例如, 下面的例子定义了万有引力常数G:

const G = 6.674e-11

const变量可以省略类型标注, 但是不可省略初始化表达式

const变量可以是全局变量, 局部变量, 静态成员变量

但是const变量不能在扩展中定义

const变量可以访问对应类型的所有实例成员, 也可以调用对应类型的所有非mut实例成员函数

const变量, 在编译时求值, 运行时禁止修改

const变量在定义时, 必须进行初始化, 即:

const nonInitCVar: Int64 // Error, const 变量声明必须初始化
const initCVar = 10 // OK

const变量可以访问所有非mut实例成员

下例定义了一个struct, 记录行星的质量和半径, 同时定义了一个const成员函数gravity用来计算该行星对距离为r质量为m的物体的万有引力:

struct Planet {
const Planet(let mass: Float64, let radius: Float64) {}
const func gravity(m: Float64, r: Float64) {
G * mass * m / r ** 2
}
}
main() {
const myMass = 71.0
const earth = Planet(5.972e24, 6.378e6)
println(earth.gravity(myMass, earth.radius))
}

编译执行得到地球对地面上一个质量为71kg 的成年人的万有引力:

695.657257

const变量初始化后该类型实例的所有成员都是const的(深度const, 包含成员的成员), 因此不能被用于左值

main() {
const myMass = 71.0
myMass = 70.0 // Error, 无法赋值于不可变值
}

const变量初始化之后, 该实例的所有直接或间接的成员均为const, 被称为深度const


const变量在定义时, 必须要进行初始化, 而初始化所使用的表达式, 必须是const表达式

const表达式可以是const变量, 可以是数据的字面量, 可以是const表达式, 也可以是const函数等

但, 不能是其他类型变量或函数等

const cG = 6.674e-11
let letG = 6.674e-11
func getName() {
return "humid1ch"
}
const func getCName() {
return "humid1ch"
}
main() {
const cName = getCName() // OK
const tryCName = getName() // Error, 'const' 表达式, 保证在编译时进行计算
const newCG = cG // OK
const tryCG = letG // Error, 'const' 表达式, 保证在编译时进行计算
}
值类型和引用类型变量#

编译器实现层面看, 任何变量总会关联一个值(一般是通过内存地址/寄存器关联), 只是在使用时:

  1. 对有些变量, 将直接取用这个值本身, 这被称为值类型变量

  2. 而对另一些变量, 将这个值作为索引、取用这个索引指示的数据, 这被称为引用类型变量

值类型变量通常在线程栈上分配, 每个变量都有自己的数据副本; 引用类型变量通常在进程堆中分配, 多个变量可引用同一数据对象, 对一个变量执行的操作可能会影响其他变量

语言层面看, 值类型变量对它所绑定的数据/存储空间是独占的, 而引用类型变量所绑定的数据/存储空间可以和其他引用类型变量共享

基于上述原理, 在使用值类型变量和引用类型变量时, 会存在一些行为差异, 以下几点值得注意:

  • 在给值类型变量赋值时, 一般会产生拷贝操作, 且原来绑定的数据/存储空间会被覆盖

    在给引用类型变量赋值时, 只是改变了引用关系, 原来绑定的数据/存储空间不会被覆盖

  • let定义的变量, 要求变量被初始化后都不能再赋值

    对于引用类型, 这只是限定了引用关系不可改变, 但是所引用的数据是可以被修改的

值类型变量和引用类型变量, 在某些方面的操作会存在一些差异

对于值类型变量, 给值类型变量赋值, 会直接改变变量地址的数据

且, 给值类型变量赋值, 如果=右边是另外一个变量时, 则发生拷贝动作, 最终两个变量之间没有任何关联

对于引用类型变量, 给引用类型变量赋值, 只是改变了变量的引用关系

给引用类型变量赋值, 如果=右边是另一个变量是, 左边的变量会引用右边的变量, 两个变量最终共享同一个实例

let修饰值类型变量, 要求变量初始化之后不允许再直接赋值, 包括成员

let修饰引用类型变量, 只限制引用关系禁止修改

在仓颉编程语言中, classArray等类型属于引用类型, 其他基础数据类型和struct等类型属于值类型

例如, 以下程序演示了structclass类型变量的行为差异:

struct Copy {
var data = 2012
}
class Share {
var data = 2012
}
main() {
let c1 = Copy()
var c2 = c1
c2.data = 2023
println("${c1.data}, ${c2.data}")
let s1 = Share()
let s2 = s1
s2.data = 2023
println("${s1.data}, ${s2.data}")
}

运行以上程序, 将输出:

2012, 2023
2023, 2023

由此可以看出, 对于值类型的Copy类型变量, 在赋值时总是获取Copy实例的拷贝, 如c2 = c1, 随后对c2成员的修改并不影响c1

对于引用类型的Share类型变量, 在赋值时将建立变量和实例之间的引用关系, 如s2 = s1, 随后对s2成员的修改会影响s1

如果将以上程序中的var c2 = c1改成let c2 = c1, 则编译会报错, 例如:

struct Copy {
var data = 2012
}
main() {
let c1 = Copy()
let c2 = c1
c2.data = 2023 // Error, 无法赋值于不可变值
}

文档中演示了值类型struct和 引用类型class使用的差异

作用域#

在前文中, 初步介绍了如何给仓颉程序元素命名, 实际上, 除了变量, 还可以给函数和自定义类型等命名, 在程序中将使用这些名字访问对应的程序元素

但在实际应用中, 需要考虑一些特殊情况:

  • 当程序规模较大时, 那些简短的名字很容易重复, 即产生命名冲突

  • 结合运行时考虑, 在有些代码片段中, 另一些程序元素是无效的, 对它们的引用会导致运行时错误

  • 在某些逻辑构造中, 为了表达元素之间的包含关系, 不应通过名字直接访问子元素, 而是要通过其父元素名间接访问

为了应对这些问题, 现代编程语言引入了”作用域”的概念及设计, 将名字和程序元素的绑定关系限制在一定范围里

不同作用域之间可以是并列或无关的, 也可以是嵌套或包含关系

一个作用域将明确开发者能用哪些名字访问哪些程序元素, 具体规则是:

  1. 当前作用域中, 定义的程序元素与名字的绑定关系, 在当前作用域和其内层作用域中是有效的, 可以通过此名字直接访问对应的程序元素

  2. 内层作用域中, 定义的程序元素与名字的绑定关系, 在外层作用域中无效

  3. 内层作用域可以使用外层作用域中的名字重新定义绑定关系, 根据规则 1, 此时内层作用域中的命名相当于遮盖了外层作用域中的同名定义, 对此称内层作用域的级别比外层作用域的级别高

在仓颉编程语言中, 用一对大括号"{}"包围一段仓颉代码, 即构造了一个新的作用域, 其中可以继续使用大括号"{}"包围仓颉代码, 由此产生了嵌套作用域, 这些作用域均服从上述规则

特别的, 在一个仓颉源文件中, 不被任何大括号"{}"包围的代码, 它们所属的作用域被称为**“顶层作用域”, 即当前文件中”最外层”的作用域, 按上述规则, 其作用域级别最低**

注意:

仓颉不允许使用单独的大括号"{}", 大括号必须依赖ifmatch、函数体、类体、结构体等其他语法结构存在

作用域的功能, 是 将 名字与程序元素的绑定关系 限制在一定的范围中

什么意思呢?

就是, 如果存在作用域1和作用域2, 如下代码:

func f1() {
// 这里是 作用域1 的范围
var value = 1
println(value)
}
func f2() {
// 这里是 作用域2 的范围
var value = 2
println(value)
}

这两个作用域范围中, 都存在变量名为value的变量(名字:value, 程序元素:Int64类型变量), 但这两个作用域内的两个同名变量是互不相干的, 他们被限制在了各自的作用域范围中

此时, 各自函数中的println(value)访问到的value就是各自作用域范围内的可用value, f1中不会访问到f2中的value, f2也不会访问到f1中的value

如果不存在作用域的概念, 那么也就是说这两个变量的使用范围不存在限制, 那么 定义同名变量就会出问题, 更别说使用

上面两个函数的作用域, 可以说他们是并列/无关的

除此之外, 仓颉中的作用域可能还存在嵌套/包含的关系:

func fOut() {
// 这里是 外层作用域 in1
var value = 2
println("in1: ${value}")
func fIn() {
// 这里是 内层作用域 out1
println("out1: 定义value之前 ${value}")
var value = 3
println("out1: 定义value之后 ${value}")
if (value != 2) {
// 这里是 更内层的作用域 out2
println("out2: 定义value之前 ${value}")
var value = 4
println("out2: 定义value之后 ${value}")
}
}
fIn()
}

此例中出现了存在嵌套/包含关系的三个作用域:in1 <- out1 <- out2

而且, 从执行结果可以发现: 各作用域之间可以存在同名变量, 且 内层作用域定义同名变量之前 访问外层的同名变量, 定义同名变量之后, 访问新定义的同名变量

仓颉将这种行为, 称为遮盖, 即 内层作用域变量 遮盖 外层作用域的同名变量

越内层的作用域级别越高, 越外层的作用域级别越高, 顶层作用域的作用域级别最低

例如在以下名为test.cj的仓颉源文件里

在顶层作用域中定义了名字element, 它和字符串"仓颉"绑定, 而mainif引导的代码块中也定义了名字element, 分别对应整数9和整数2023

由上述作用域规则, 在第4行, element的值为”仓颉”, 在第8行, element的值为2023, 在第10行, element的值为 9

test.cj
let element = "仓颉"
main() {
println(element)
let element = 9
if (element > 0) {
let element = 2023
println(element)
}
println(element)
}

运行以上程序, 将输出:

仓颉
2023
9

上述文档中的例子也展示了作用域的相关内容

作者
Humid1ch
发布于
2025-10-21
许可协议
CC BY-NC-SA 4.0