作用域

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

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

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

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

在某些逻辑构造中,为了表达元素之间的包含关系,不应通过名字直接访问子元素,而是要通过其父元素名间接访问。
为了应对这些问题,现代编程语言引入了“作用域”的概念及设计,将名字和程序元素的绑定关系限制在一定范围里。不同作用域之间可以是并列或无关的,也可以是嵌套或包含关系。一个作用域将明确开发者能用哪些名字访问哪些程序元素,具体规则是:

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

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

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

在仓颉编程语言中,用一对大括号“{}”包围一段仓颉代码,即构造了一个新的作用域,其中可以继续使用大括号“{}”包围仓颉代码,由此产生了嵌套作用域,这些作用域均服从上述规则。特别的,在一个仓颉源文件中,不被任何大括号“{}”包围的代码,它们所属的作用域被称为“顶层作用域”,即当前文件中“最外层”的作用域,按上述规则,其作用域级别最低。

注意:

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

例如在以下名为 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)
}

程序结构
通常,开发者会在扩展名为 .cj 的文本文件中编写仓颉程序,这些程序和文件也被称为源代码和源文件。在程序开发的最后阶段,这些源代码将被编译为特定格式的二进制文件。

在仓颉程序的顶层作用域中,可以定义一系列的变量、函数和自定义类型(如 struct、class、enum 和 interface 等),其中的变量和函数分别被称为全局变量和全局函数。如果要将仓颉程序编译为可执行文件,需要在顶层作用域中定义一个 main 函数作为程序入口,它可以有 Array 类型的参数,也可以没有参数,它的返回值类型可以是整数类型或 Unit 类型。

注意:

定义 main 函数时,不需要写 func 修饰符。此外,如果需要获取程序启动时的命令行参数,可以声明和使用 Array 类型参数。

例如,在以下程序中,在顶层作用域定义了全局变量 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
变量
在仓颉编程语言中,一个变量由对应的变量名、数据(值)和若干属性构成,开发者通过变量名访问变量对应的数据,但访问操作需要遵从相关属性的约束(如数据类型、可变性和可见性等)。

变量定义的具体形式为:

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

可变性修饰符:let 与 var,分别对应不可变和可变属性,可变性决定了变量被初始化后其值还能否改变,仓颉变量也由此分为不可变变量和可变变量两类。
const 修饰符:const 是一种特殊的变量修饰符。它用于声明常量,要求在声明时必须初始化,一旦被赋值,其值就不能被改变。这与let 修饰符类似,都具有不可变的特性,但const 在使用上有更严格的限制。
可见性修饰符:private 与 public 等,影响全局变量和成员变量的可引用范围,详见后续章节的相关介绍。
静态性修饰符:static,影响成员变量的存储和引用方式,详见后续章节的相关介绍。
变量均支持赋值操作符(=),与类型无关。let 修饰的变量只能被赋值一次,即初始化;var 修饰的变量可以被多次赋值。

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

变量名应是一个合法的仓颉标识符。
变量类型指定了变量所持有数据的类型。当初始值具有明确类型时,可以省略变量类型标注,此时编译器可以自动推断出变量类型。
初始值是一个仓颉表达式,用于初始化变量,如果标注了变量类型,需要保证初始值类型和变量类型一致。在定义全局变量或静态成员变量时,必须指定初始值。在定义局部变量或实例成员变量时,可以省略初始值,但需要标注变量类型,同时要在此变量被引用前完成初始化,否则编译会报错。
例如,下列程序定义了三个 Int64 类型的变量(分别为不可变变量 a 、可变变量 b 和 const 变量 c )。随后修改了变量 b 的值,同时将 b 的值赋值给a,并调用 println 函数打印 a, b 和c 的值。

main() {
let a: Int64
var b: Int64 = 14
const c: Int64 = 13
b = 12
a = b // A variable modified by let can only be assigned once, that is, initialized
println(“${a}, ${b}, ${c}”)
}
编译运行此程序,将输出:

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

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)
}
编译运行此程序,将输出:

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

let global: Int64 // Error, variable in top-level scope must be initialized

main(): Unit{

}
class Player {
static let score: Int32 // Error, static variable ‘score’ needs to be initialized when declaring
}
需要注意的是,当编译器无法判断某些场景是否一定会被初始化或无法判断是否重复初始化了不可变变量时,会倾向于保守策略进行编译报错,见如下示例:

func calc(a: Int32){
println(a)
return a * a
}
main() {
let a: String
if(calc(32) == 0){
a = “1”
}
a = “2” // Error, cannot assign to immutable value
}
此外,对于 try-catch 场景,编译器会假设 try 块总是全部被执行,且总是抛异常,从而进行相关报错,见如下示例:

main() {
let a: String
try {
a = “1”
} catch (_) {
a = “2” // Error, cannot assign to immutable value
}
}
const 变量
const 变量是一种特殊的变量,它以关键字 const 修饰,定义在编译时完成求值,并且在运行时不可改变的变量。例如,下面的例子定义了万有引力常数 G:

const G = 6.674e-11
const 变量可以省略类型标注,但是不可省略初始化表达式。const 变量可以是全局变量,局部变量,静态成员变量。但是 const 变量不能在扩展中定义。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))
}
编译执行得到地球对地面上一个质量为 71 kg 的成年人的万有引力:

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

main() {
const myMass = 71.0
myMass = 70.0 // Error, cannot assign to immutable value
}
值类型和引用类型变量
从编译器实现层面看,任何变量总会关联一个值(一般是通过内存地址/寄存器关联),只是在使用时,对有些变量,将直接取用这个值本身,这被称为值类型变量;而对另一些变量,将这个值作为索引、取用这个索引指示的数据,这被称为引用类型变量。值类型变量通常在线程栈上分配,每个变量都有自己的数据副本;引用类型变量通常在进程堆中分配,多个变量可引用同一数据对象,对一个变量执行的操作可能会影响其他变量。

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

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

在给值类型变量赋值时,一般会产生拷贝操作,且原来绑定的数据/存储空间会被覆盖。在给引用类型变量赋值时,只是改变了引用关系,原来绑定的数据/存储空间不会被覆盖。
用 let 定义的变量,要求变量被初始化后都不能再赋值。对于引用类型,这只是限定了引用关系不可改变,但是所引用的数据是可以被修改的。
在仓颉编程语言中,class 和 Array 等类型属于引用类型,其他基础数据类型和 struct 等类型属于值类型。

例如,以下程序演示了 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
}
作用域
在前文中,初步介绍了如何给仓颉程序元素命名,实际上,除了变量,还可以给函数和自定义类型等命名,在程序中将使用这些名字访问对应的程序元素。

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

当程序规模较大时,那些简短的名字很容易重复,即产生命名冲突。
结合运行时考虑,在有些代码片段中,另一些程序元素是无效的,对它们的引用会导致运行时错误。
在某些逻辑构造中,为了表达元素之间的包含关系,不应通过名字直接访问子元素,而是要通过其父元素名间接访问。
为了应对这些问题,现代编程语言引入了“作用域”的概念及设计,将名字和程序元素的绑定关系限制在一定范围里。不同作用域之间可以是并列或无关的,也可以是嵌套或包含关系。一个作用域将明确开发者能用哪些名字访问哪些程序元素,具体规则是:

当前作用域中定义的程序元素与名字的绑定关系,在当前作用域和其内层作用域中是有效的,可以通过此名字直接访问对应的程序元素。
内层作用域中定义的程序元素与名字的绑定关系,在外层作用域中无效。
内层作用域可以使用外层作用域中的名字重新定义绑定关系,根据规则 1,此时内层作用域中的命名相当于遮盖了外层作用域中的同名定义,对此称内层作用域的级别比外层作用域的级别高。
在仓颉编程语言中,用一对大括号“{}”包围一段仓颉代码,即构造了一个新的作用域,其中可以继续使用大括号“{}”包围仓颉代码,由此产生了嵌套作用域,这些作用域均服从上述规则。特别的,在一个仓颉源文件中,不被任何大括号“{}”包围的代码,它们所属的作用域被称为“顶层作用域”,即当前文件中“最外层”的作用域,按上述规则,其作用域级别最低。

注意:

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

例如在以下名为 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

表达式

在一些传统编程语言中,一个表达式由一个或多个操作数(operand)通过零个或多个操作符(operator)组合而成。表达式总是隐含着一个计算过程,因此每个表达式都会有一个计算结果。对于只有操作数而没有操作符的表达式,其计算结果就是操作数自身。对于包含操作符的表达式,计算结果是对操作数执行操作符定义的计算而得到的值。在这种定义下的表达式也被称为算术运算表达式。操作符优先级请参见操作符章节。

在仓颉编程语言中,简化并延伸了表达式的传统定义——凡是可求值的语言元素都是表达式。因此,仓颉不仅有传统的算术运算表达式,还有条件表达式、循环表达式和 try 表达式等,它们都可以被求值,并作为值去使用,如作为变量定义的初值和函数实参等。此外,因为仓颉是强类型的编程语言,所以仓颉表达式不仅可求值,还有确定的类型。

注意:

为了清晰地划分不同的程序语句或表达式,仓颉采用分号(;)进行分隔。如果一条语句独占一行,该分号可以省略,但一行存在多条语句,这些语句必须用分号进行分隔。

仓颉编程语言的各种表达式将在后续章节中逐一介绍,本节介绍最常用的条件表达式、循环表达式以及部分控制转移表达式(break、continue)。

任何一段程序的执行流程,只会涉及三种基本结构——顺序结构、分支结构和循环结构。实际上,分支结构和循环结构,是由某些指令控制当前顺序执行流产生跳转而得到的,它们让程序能够表达更复杂的逻辑。在仓颉中,这种用来控制执行流的语言元素就是条件表达式和循环表达式。

在仓颉编程语言中,条件表达式是 if 表达式,其值与类型需要根据使用场景来确定。循环表达式有三种:for-in 表达式、while 表达式和 do-while 表达式,它们的类型都是 Unit,值为 ()。

在仓颉程序中,由一对大括号“{}”包围起来的一组表达式,被称为“代码块”,它将作为程序的一个顺序执行流,其中的表达式将按编码顺序依次执行。如果代码块中有至少一个表达式,规定此代码块的值与类型等于其中最后一个表达式的值与类型,如果代码块中没有表达式,规定这种空代码块的类型为 Unit,值为 ()。

注意:

代码块本身不是一个表达式,不能被单独使用,它将依附于函数、条件表达式和循环表达式等执行和求值。

if 表达式

if 表达式的基本形式为:

if (条件) {
  分支 1
} else {
  分支 2
}

其中“条件”可以是一个布尔类型的表达式,或者一个 “let pattern” (语法糖),或者多个 “let pattern” 和布尔类型的表达式之间通过逻辑与或逻辑或直接连接形成的表达式,涉及 “let pattern” 的介绍和示例,参照涉及 “let pattern” 的“条件”示例。

当表达式和模式匹配成功时,该模式匹配的值为 true,此时执行 if 分支对应的代码块;反之,为 false,执行 else 分支代码块,else 分支可以不存在。

“分支 1”和“分支 2”是两个代码块。if 表达式将按如下规则执行:

计算“条件”表达式,如果值为 true 则转到第 2 步,值为 false 则转到第 3 步。
执行“分支 1”,转到第 4 步。
执行“分支 2”,转到第 4 步。
继续执行 if 表达式后面的代码。

在一些场景中,可能只关注条件成立时该做些什么,所以 else 和对应的代码块是允许省略的。

如下程序演示了 if 表达式的基本用法:

import std.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) { // 编译错误,类型不匹配
        println("非零数")
    }
}

在许多场景中,当一个条件不成立时,可能还要判断另一个或多个条件,再执行对应的动作。仓颉允许在 else 之后跟随新的 if 表达式,由此支持多级条件判断和分支执行,例如:

import std.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。

涉及 “let pattern” 的“条件”示例

“let pattern” 属于语法糖。一个 “let pattern” 的构成为 let pattern <- expression,其中各字段含义为:

pattern :模式,用于匹配 expression 的值类型和内容。
<- :模式匹配操作符。

expression :表达式,该表达式求值后,再和模式进行匹配。

expression 表达式的优先级不能低于 … 运算符,但是可以用 () 改变优先级。运算符优先级请参见操作符。

此处介绍“条件”是两个 “let pattern” 进行逻辑与或逻辑或操作以及 “let pattern” 与其他表达式进行逻辑与或逻辑或操作的示例。

main() {
    let a = Some(3)
    let c = if (let Some(b) <- a) {
            1 // 模式匹配成功,c = 1
        } else {
            2
        }
    let d = Some(1)

    if (let Some(e) <- a && let Some(f) <- d) { // 两种模式都匹配,条件的值为真
        println("${e} ${f}") // print 3 1
    }

    if (let Some(f) <- d && f > 3) { // 模式匹配;f = 1,f > 3 检查失败,跳转到 else 分支
        println("${f}")
    } else {
        println("d is None or value of d is less or equal to 3") // 打印该行
    }

    if (let Some(_) <- a || let Some(_) <- d) { // 枚举模式通过||连接,没有变量绑定,正确
        println("at least one of a and d is Some") // 打印该行
    } else {
        println("both a and d are None")
    }

    let g = 3
    if (let Some(_) <- a || g > 1) {
        println("this") // 打印该行
    } else {
        println("that")
    }
}

“let pattern” 中表达式部分运算符优先级不能低于 … 运算符,此处介绍对应的错误和正确示例。其中, Option 类型的相关介绍在后文给出。

if (let Some(a) <- fun() as Option<Int64>) {}   // 解析错误,`as` 的优先级低于  
if (let Some(a) <- (fun() as Option<Int64>)) {} // 正确
if (let Some(a) <- b && a + b > 3) {}           // 正确,解析为 (let Some(a) <- b) && (a + b > 3)
if (let m <- 0..generateSomeInt()) {}           // 正确

错误的表达式示例

此处介绍错误的“条件”示例。

if (let Some(a) <- b || a > 1) {} // 由 `||` 连接的条件不能使用会绑定变量的 enum 模式
if (let Some(a) <- b && a + 1) {} // `&&` 右侧既不是 let pattern,也不是类型为 Bool 的普通表达式
if (a > 3 && let Some(a) <- b) {} // a 由 Some(a) pattern 绑定,不能在绑定它的 pattern 左侧使用
if (let Some(a) <- b && a > 3) {
    println("${a} > 3")
} else {
    println("${a} < 3") // a 只能在 if 分支使用,不能在 else 分支使用
}
if (let Some(a) <- b where a > 3) {} // 使用 `&&` 表示条件检查,而不是 `where`
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐