基本概念
大多数编程语言都有一些共通的概念或元素,例如变量和表达式等,因为它们是逻辑表达的要素,同时和计算机的存储及指令有较为直接的对应关系。此外,还有程序入口、作用域、全局/局部变量等概念,这些概念和相关的规则,将作为开发者编写程序的基本章法。本章将介绍仓颉编程语言中的这些基本概念。
标识符
在仓颉编程语言中,开发者可以给一些程序元素命名,这些名字也被称为“标识符”,标识符分为普通标识符和原始标识符两类,它们分别遵从不同的命名规则。
普通标识符不能和仓颉关键字相同,可以取自以下两类字符序列:
- 由英文字母开头,后接零至多个英文字母、数字或下划线“_”。
- 由一至多个下划线“_”开头,后接一个英文字母,最后可接零至多个英文字母、数字或下划线“_”。
例如,以下每行字符串都是合法的普通标识符:
abc
_abc
abc_
a1b2c3
a_b_c
a1_b2_c3
以下每行字符串都是不合法的普通标识符:
ab&c // 使用了非法字符 “&”
_123 // 起始下划线 “_” 后不能接数字
3abc // 数字不能出现在头部
while // 不能使用仓颉关键字
原始标识符是在普通标识符或仓颉关键字的外面加上一对反引号,主要用于将仓颉关键字作为标识符的场景。
例如,以下每行字符串都是合法的原始标识符:
abc
_abc
a1b2c3
if
while
以下每行字符串,由于反引号内的部分是不合法的普通标识符,所以它们整体也是不合法的原始标识符:
ab&c
_123
3abc
变量
在仓颉编程语言中,一个变量由对应的变量名、数据(值)和若干属性构成,开发者通过变量名访问变量对应的数据,但访问操作需要遵从相关属性的约束(如数据类型、可变性和可见性等)。当程序被编译运行时,一个变量可以具化为一份可操作的存储空间。
因此,在定义一个仓颉变量时,就需要指定变量名、初始值和相关属性。变量定义的具体形式为:
修饰符 变量名: 变量类型 = 初始值
其中修饰符用于设置变量的各类属性,可以有一个或多个,常用的修饰符包括:
- 可变性修饰符:
let
与var
,分别对应不可变和可变属性,可变性决定了变量被初始化后其值还能否改变,仓颉变量也由此分为不可变变量和可变变量两类。 - 可见性修饰符:
private
与public
等,影响全局变量和成员变量的可引用范围,详见后续章节的相关介绍。 - 静态性修饰符:
static
,影响成员变量的存储和引用方式,详见后续章节的相关介绍。
在定义仓颉变量时,可变性修饰符是必要的,在此基础上,还可以根据需要添加其他修饰符。
变量名应是一个合法的仓颉标识符。
变量类型指定了变量所持有数据的类型。当初始值具有明确类型时,可以省略变量类型标注,此时编译器可以自动推断出变量类型。
初始值是一个仓颉表达式,用于初始化变量,如果标注了变量类型,需要保证初始值类型和变量类型一致。在定义全局变量或静态成员变量时,必须指定初始值。在定义局部变量或实例成员变量时,可以省略初始值,但需要标注变量类型,同时要在此变量被引用前完成初始化,否则编译会报错。
例如,下列程序定义了两个 Int64
类型的不可变变量 a
和可变变量 b
,随后修改了变量 b
的值,并调用仓颉标准库中的 println
函数打印 a
与 b
的值。
main() {
let a: Int64 = 20
var b: Int64 = 12
b = 23
println("${a}${b}")
}
编译运行此程序,将输出:
2023
如果尝试修改不可变变量,编译时会报错,例如:
main() {
let pi: Float64 = 3.14159
pi = 2.71828 // error: cannot assign to immutable value
}
当初始值具有明确类型时,可以省略变量类型标注,例如:
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)
}
编译运行此程序,将输出:
仓颉造字
在定义全局变量和静态成员变量时必须初始化,否则编译会报错,例如:
// example.cj
let global: Int64 // error: variable in top-level scope must be initialized
// example.cj
class Player {
static let score: Int32 // error: static variable 'score' needs to be initialized when declaring
}
值类型和引用类型变量
程序在运行阶段,只有指令流转和数据变换,仓颉程序中的各种标识符已不复存在。由此可见,编译器使用了一些机制,将这些名字和编程所取用的数据实体/存储空间绑定起来。
从编译器实现层面看,任何变量总会关联一个值(一般是通过内存地址/寄存器关联),只是在使用时,对有些变量,我们将直接取用这个值本身,这被称为值类型变量,而对另一些变量,我们把这个值作为索引、取用这个索引指示的数据,这被称为引用类型变量。值类型变量通常在线程栈上分配,每个变量都有自己的数据副本;引用类型变量通常在进程堆中分配,多个变量可引用同一数据对象,对一个变量执行的操作可能会影响其他变量。
从语言层面看,值类型变量对它所绑定的数据/存储空间是独占的,而引用类型变量所绑定的数据/存储空间可以和其他引用类型变量共享。
基于上述原理,在使用值类型变量和引用类型变量时,会存在一些行为差异,值得注意以下几点:
- 在给值类型变量赋值时,一般会产生拷贝操作,且原来绑定的数据/存储空间被覆写。在给引用类型变量赋值时,只是改变了引用关系,原来绑定的数据/存储空间不会被覆写。
- 用
let
定义的变量,要求变量被初始化后都不能再赋值。对于引用类型,这只是限定了引用关系不可改变,但是所引用的数据是可以被修改的。
在仓颉编程语言中,基础数据类型和 struct
等类型属于值类型,而 class
和 Array
等类型属于引用类型。
例如,以下程序演示了 struct
和 class
类型变量的行为差异:
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: cannot assign to immutable value
}
表达式
在一些传统编程语言中,一个表达式由一个或多个操作数(operand)通过零个或多个操作符(operator)组合而成,表达式总是隐含着一个计算过程,因此每个表达式都会有一个计算结果,对于只有操作数而没有操作符的表达式,其计算结果就是操作数自身,对于包含操作符的表达式,计算结果是对操作数执行操作符定义的计算而得到的值。在这种定义下的表达式也被称为算术运算表达式。
在仓颉编程语言中,我们简化并延伸了表达式的传统定义——凡是可求值的语言元素都是表达式。因此,仓颉不仅有传统的算术运算表达式,还有条件表达式、循环表达式和 try
表达式等,它们都可以被求值,并作为值去使用,如作为变量定义的初值和函数实参等。此外,因为仓颉是强类型的编程语言,所以仓颉表达式不仅可求值,还有确定的类型。
仓颉编程语言的各种表达式将在后续章节中逐一介绍,本节只介绍最常用的条件表达式和循环表达式。
我们知道,任何一段程序的执行流程,只会涉及三种基本结构——顺序结构、分支结构和循环结构。实际上,分支结构和循环结构,是由某些指令控制当前顺序执行流产生跳转而得到的,它们让程序能够表达更复杂的逻辑,在仓颉中,这种用来控制执行流的语言元素就是条件表达式和循环表达式。
在仓颉编程语言中,条件表达式分为 if
表达式和 if-let
表达式两种,它们的值与类型需要根据使用场景来确定。循环表达式有四种:for-in
表达式、while
表达式、do-while
表达式和 while-let
表达式,它们的类型都是 Unit
、值为 ()
。其中 if-let
表达式和 while-let
表达式都与模式匹配相关,请参见模式匹配章节,本节只介绍以上提及的其他几种表达式。
在仓颉程序中,由一对大括号“{}”包围起来的一组表达式,被称为“代码块”,它将作为程序的一个顺序执行流,其中的表达式将按编码顺序依次执行。如果代码块中有至少一个表达式,我们规定此代码块的值与类型等于其中最后一个表达式的值与类型,如果代码块中没有表达式,规定这种空代码块的类型为
Unit
、值为()
。请注意,代码块本身不是一个表达式,不能被单独使用,它将依附于函数、条件表达式和循环表达式等执行和求值。
if 表达式
if
表达式的基本形式为:
if (条件) {
分支 1
} else {
分支 2
}
其中“条件”是布尔类型表达式,“分支 1”和“分支 2”是两个代码块。if
表达式将按如下规则执行:
- 计算“条件”表达式,如果值为
true
则转到第 2 步,值为false
则转到第 3 步。 - 执行“分支 1”,转到第 4 步。
- 执行“分支 2”,转到第 4 步。
- 继续执行
if
表达式后面的代码。
在一些场景中,我们可能只关注条件成立时该做些什么,所以 else
和对应的代码块是允许省略的。
如下程序演示了 if
表达式的基本用法:
from std import random.*
main() {
let number: Int8 = Random().nextInt8()
println(number)
if (number % 2 == 0) {
println("偶数")
} else {
println("奇数")
}
}
在这段程序中,我们使用仓颉标准库的 random
包生成了一个随机整数,然后使用 if
表达式判断这个整数是否能被 2 整除,并在不同的条件分支中打印“偶数”或“奇数”。
仓颉编程语言是强类型的,if
表达式的条件只能是布尔类型,不能使用整数或浮点数等类型,和 C 语言等不同,仓颉不以条件取值是否为 0 作为分支选择依据,例如以下程序将编译报错:
main() {
let number = 1
if (number) { // error: mismatched types
println("非零数")
}
}
在许多场景中,当一个条件不成立时,我们可能还要判断另一个或多个条件、再执行对应的动作,仓颉允许在 else
之后跟随新的 if
表达式,由此支持多级条件判断和分支执行,例如:
from std import random.*
main() {
let speed = Random().nextFloat64() * 20.0
println("${speed} km/s")
if (speed > 16.7) {
println("第三宇宙速度,鹊桥相会")
} else if (speed > 11.2) {
println("第二宇宙速度,嫦娥奔月")
} else if (speed > 7.9) {
println("第一宇宙速度,腾云驾雾")
} else {
println("脚踏实地,仰望星空")
}
}
if
表达式的值与类型,需要根据使用形式与场景来确定:
-
当含
else
分支的if
表达式被求值时,需要根据求值上下文确定if
表达式的类型:-
如果上下文明确要求值类型为
T
,则if
表达式各分支代码块的类型必须是T
的子类型,这时if
表达式的类型被确定为T
,如果不满足子类型约束,编译会报错。 -
如果上下文没有明确的类型要求,则
if
表达式的类型是其各分支代码块类型的最小公共父类型,如果最小公共父类型不存在,编译会报错。
如果编译通过,则
if
表达式的值就是所执行分支代码块的值。 -
-
如果含
else
分支的if
表达式没有被求值,在这种场景里,开发者一般只想在不同分支里做不同操作,不会关注各分支最后一个表达式的值与类型,为了不让上述类型检查规则影响这一思维习惯,仓颉规定这种场景下的if
表达式类型为Unit
、值为()
,且各分支不参与上述类型检查。 -
对于不含
else
分支的if
表达式,由于if
分支也可能不被执行,所以我们规定这类if
表达式的类型为Unit
、值为()
。
例如,以下程序基于 if
表达式求值,模拟一次简单的模数转换过程:
main() {
let zero: Int8 = 0
let one: Int8 = 1
let voltage = 5.0
let bit = if (voltage < 2.5) {
zero
} else {
one
}
}
在以上程序中,if
表达式作为变量定义的初值使用,由于变量 bit
没有被标注类型、需要从初值中推导,所以 if
表达式的类型取为两个分支代码块类型的最小公共父类型,根据前文对“代码块”的介绍,可知两个分支代码块类型都是 Int8
,所以 if
表达式的类型被确定为 Int8
,其值为所执行分支即 else
分支代码块的值,所以变量 bit
的类型为 Int8
、值为 1。
while 表达式
while
表达式的基本形式为:
while (条件) {
循环体
}
其中“条件”是布尔类型表达式,“循环体”是一个代码块。while
表达式将按如下规则执行:
- 计算“条件”表达式,如果值为
true
则转第 2 步,值为false
转第 3 步。 - 执行“循环体”,转第 1 步。
- 结束循环,继续执行
while
表达式后面的代码。
例如,以下程序使用 while
表达式,基于二分法,近似计算数字 2 的平方根:
main() {
var root = 0.0
var min = 1.0
var max = 2.0
var error = 1.0
let tolerance = 0.1 ** 10
while (error ** 2 > tolerance) {
root = (min + max) / 2.0
error = root ** 2 - 2.0
if (error > 0.0) {
max = root
} else {
min = root
}
}
println("2 的平方根约等于:${root}")
}
运行以上程序,将输出:
2 的平方根约等于:1.414215
do-while 表达式
do-while
表达式的基本形式为:
do {
循环体
} while (条件)
其中“条件”是布尔类型表达式,“循环体”是一个代码块。do-while
表达式将按如下规则执行:
- 执行“循环体”,转第 2 步。
- 计算“条件”表达式,如果值为
true
则转第 1 步,值为false
转第 3 步。 - 结束循环,继续执行
do-while
表达式后面的代码。
例如,以下程序使用 do-while
表达式,基于蒙特卡洛算法,近似计算圆周率的值:
from std import random.*
main() {
let random = Random()
var totalPoints = 0
var hitPoints = 0
do {
// 在 ((0, 0), (1, 1)) 这个正方形中随机取点
let x = random.nextFloat64()
let y = random.nextFloat64()
// 判断是否落在正方形内接圆里
if ((x - 0.5) ** 2 + (y - 0.5) ** 2 < 0.25) {
hitPoints++
}
totalPoints++
} while (totalPoints < 1000000)
let pi = 4.0 * Float64(hitPoints) / Float64(totalPoints)
println("圆周率近似值为:${pi}")
}
运行以上程序,将输出:
圆周率近似值为:3.141872
由于算法涉及随机数,所以每次运行程序输出的数值可能都不同,但都会约等于 3.14。
for-in 表达式
for-in
表达式可以遍历那些扩展了迭代器接口 Iterable<T>
的类型实例。for-in
表达式的基本形式为:
for (迭代变量 in 序列) {
循环体
}
其中“循环体”是一个代码块。“迭代变量”是单个标识符或由多个标识符构成的元组,用于绑定每轮遍历中由迭代器指向的数据,可以作为“循环体”中的局部变量使用。“序列”是一个表达式,它只会被计算一次,遍历是针对此表达式的值进行的,其类型必须扩展了迭代器接口 Iterable<T>
。for-in
表达式将按如下规则执行:
- 计算“序列”表达式,将其值作为遍历对象,并初始化遍历对象的迭代器。
- 更新迭代器,如果迭代器终止,转第 4 步,否则转第 3 步。
- 将当前迭代器指向的数据与“迭代变量”绑定,并执行“循环体”,转第 2 步。
- 结束循环,继续执行
for-in
表达式后面的代码。
仓颉内置的区间和数组等类型已经扩展了
Iterable<T>
接口。
例如,以下程序使用 for-in
表达式,遍历中国地支字符构成的数组 noumenonArray
,输出农历 2024 年各月的干支纪法:
main() {
let metaArray = ['甲', '乙', '丙', '丁', '戊',
'己', '庚', '辛', '壬', '癸']
let noumenonArray = ['寅', '卯', '辰', '巳', '午', '未',
'申', '酉', '戌', '亥', '子', '丑']
let year = 2024
// 年份对应的天干索引
let metaOfYear = ((year % 10) + 10 - 4) % 10
// 此年首月对应的天干索引
var index = (2 * metaOfYear + 3) % 10 - 1
println("农历 2024 年各月干支:")
for (noumenon in noumenonArray) {
print("${metaArray[index]}${noumenon} ")
index = (index + 1) % 10
}
}
运行以上程序,将输出:
农历 2024 年各月干支:
丙寅 丁卯 戊辰 己巳 庚午 辛未 壬申 癸酉 甲戌 乙亥 丙子 丁丑
遍历区间
for-in
表达式可以遍历区间类型实例,例如:
main() {
var sum = 0
for (i in 1..=100) {
sum += i
}
println(sum)
}
运行以上程序,将输出:
5050
关于区间类型的详细内容,请参阅“基本数据类型”章节。
遍历元组构成的序列
如果一个序列的元素是元组类型,则使用 for-in
表达式遍历时,“迭代变量”可以写成元组形式,以此实现对序列元素的解构,例如:
main() {
let array = [(1, 2), (3, 4), (5, 6)]
for ((x, y) in array) {
println("${x}, ${y}")
}
}
运行以上程序,将输出:
1, 2
3, 4
5, 6
迭代变量不可修改
在 for-in
表达式的循环体中,不能修改迭代变量,例如以下程序在编译时会报错:
main() {
for (i in 0..5) {
i = i * 10 // error: cannot assign to value which is an initialized 'let' constant
println(i)
}
}
使用通配符_
代替迭代变量
在一些应用场景中,我们只需要循环执行某些操作,但并不使用迭代变量,这时您可以使用通配符 _
代替迭代变量,例如:
main() {
var number = 2
for (_ in 0..5) {
number *= number
}
println(number)
}
运行以上程序,将输出:
4294967296
在这种场景下,如果您使用普通的标识符定义迭代变量,编译会输出“unused variable”告警,使用通配符
_
则可以避免这一告警。
where 条件
在部分循环遍历场景中,对于特定取值的迭代变量,我们可能需要直接跳过、进入下一轮循环,虽然可以使用 if
表达式和 continue
表达式在循环体中实现这一逻辑,但仓颉为此提供了更便捷的表达方式——可以在所遍历的“序列”之后用 where
关键字引导一个布尔表达式,这样在每次将进入循环体执行前,会先计算此表达式,如果值为 true
则执行循环体,反之直接进入下一轮循环。例如:
main() {
for (i in 0..8 where i % 2 == 1) { // i 为奇数才会执行循环体
println(i)
}
}
运行以上程序,将输出:
1
3
5
7
break 与 continue 表达式
在循环结构的程序中,有时我们需要根据特定条件提前结束循环或跳过本轮循环,为此仓颉引入了 break
与 continue
表达式,它们可以出现在循环表达式的循环体中,break
用于终止当前循环表达式的执行、转去执行循环表达式之后的代码,continue
用于提前结束本轮循环、进入下一轮循环。break
与 continue
表达式的类型都是 Nothing
。
例如,以下程序使用 for-in
表达式和 break
表达式,在给定的整数数组中,找到第一个能被 5 整除的数字:
main() {
let numbers = [12, 18, 25, 36, 49, 55]
for (number in numbers) {
if (number % 5 == 0) {
println(number)
break
}
}
}
当 for-in
迭代至 numbers
数组的第三个数 25 时,由于 25 可以被 5 整除,所以将执行 if
分支中的 println
和 break
,break
将终止 for-in
循环,numbers
中的后续数字不会被遍历到,因此运行以上程序,将输出:
25
以下程序使用 for-in
表达式和 continue
表达式,将给定整数数组中的奇数打印出来:
main() {
let numbers = [12, 18, 25, 36, 49, 55]
for (number in numbers) {
if (number % 2 == 0) {
continue
}
println(number)
}
}
在循环迭代中,当 number
是偶数时,continue
将被执行,这会提前结束本轮循环、进入下一轮循环,println
不会被执行,因此运行以上程序,将输出:
25
49
55
作用域
在前文中,我们初步介绍了如何给仓颉程序元素命名,实际上,除了变量,我们还可以给函数和自定义类型等命名,在程序中将使用这些名字访问对应的程序元素。
但在实际应用中,需要考虑一些特殊情况:
- 当程序规模较大时,那些简短的名字很容易重复,即产生命名冲突。
- 结合运行时考虑,在有些代码片段中,另一些程序元素是无效的,对它们的引用会导致运行时错误。
- 在某些逻辑构造中,为了表达元素之间的包含关系,不应通过名字直接访问子元素,而是要通过其父元素名间接访问。
为了应对这些问题,现代编程语言引入了“作用域”的概念及设计,将名字和程序元素的绑定关系限制在一定范围里。不同作用域之间可以是并列或无关的,也可以是嵌套或包含关系。一个作用域将明确我们能用哪些名字访问哪些程序元素,具体规则是:
- 当前作用域中定义的程序元素与名字的绑定关系,在当前作用域和其内层作用域中是有效的,可以通过此名字直接访问对应的程序元素。
- 内层作用域中定义的程序元素与名字的绑定关系,在外层作用域中无效。
- 内层作用域可以使用外层作用域中的名字重新定义绑定关系,根据规则 1,此时内层作用域中的命名相当于遮盖了外层作用域中的同名定义,对此我们称内层作用域的级别比外层作用域的级别高。
在仓颉编程语言中,用一对大括号“{}”包围一段仓颉代码,即构造了一个新的作用域,其中可以继续使用大括号“{}”包围仓颉代码,由此产生了嵌套作用域,这些作用域均服从上述规则。特别的,在一个仓颉源文件中,不被任何大括号“{}”包围的代码,它们所属的作用域被称为“顶层作用域”,即当前文件中“最外层”的作用域,按上述规则,其作用域级别最低。
用大括号“{}”包围代码构造作用域时,其中不限于使用表达式,还可以定义函数和自定义类型等,这不同于前文中提到的“代码块”概念,当然“代码块”也是一个作用域。
例如在以下名为 test.cj
的仓颉源文件里,在顶层作用域中定义了名字 element
,它和字符串“仓颉”绑定,而 main
和 if
引导的代码块中也定义了名字 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
程序结构
通常,我们都会在扩展名为 .cj
的文本文件中编写仓颉程序,这些程序和文件也被称为源代码和源文件,在程序开发的最后阶段,这些源代码将被编译为特定格式的二进制文件。
在仓颉程序的顶层作用域中,可以定义一系列的变量、函数和自定义类型(如 struct
、class
、enum
和 interface
等),其中的变量和函数分别被称为全局变量和全局函数。如果要将仓颉程序编译为可执行文件,您需要在顶层作用域中定义一个 main
函数作为程序入口,它可以有 Array<String>
类型的参数,也可以没有参数,它的返回值类型可以是整数类型或 Unit
类型。
定义
main
函数时,不需要写func
修饰符。此外,如果需要获取程序启动时的命令行参数,可以声明和使用Array<String>
类型参数。
例如在以下程序中,我们在顶层作用域定义了全局变量 a
和全局函数 b
,还有自定义类型 C
、D
和 E
,以及作为程序入口的 main
函数。
// example.cj
let a = 2023
func b() {}
struct C {}
class D {}
enum E { F | G }
main() {
println(a)
}
在非顶层作用域中不能定义上述自定义类型,但可以定义变量和函数,称之为局部变量和局部函数。特别地,对于定义在自定义类型中的变量和函数,称之为成员变量和成员函数。
enum
和interface
中仅支持定义成员函数。
例如在以下程序中,我们在顶层作用域定义了全局函数 a
和自定义类型 A
,在函数 a
中定义了局部变量 b
和局部函数 c
,在自定义类型 A
中定义了成员变量 b
和成员函数 c
。
// example.cj
func a() {
let b = 2023
func c() {
println(b)
}
c()
}
class A {
let b = 2024
public func c() {
println(b)
}
}
main() {
a()
A().c()
}
运行以上程序,将输出:
2023
2024