官网
文档
-
原生智能化
内嵌AgentDSL的编程框架,自然语言&编程语言有机融合;多Agent协同,简化符号表达,模式自由组合,支持各类智能应用开发。
-
天生全场景
轻量化可缩放运行时,模块化分层设计,内存再小也能装得下;全场景领域扩展,元编程和eDSL技术,支持面向领域声明式开发。 -
高性能
终端场景首款全并发 GC ,应用线程更流畅,响应更快。轻量化线程,并发性能更好,开销更少。 -
强安全
安全DNA融入语言设计,帮助开发者专注于业务逻辑,免于将太多精力投入到防御性编程中,编码即安全,漏洞无处藏。
仓颉编程语言作为一款面向全场景应用开发的现代编程语言,通过现代语言特性的集成、全方位的编译优化和运行时实现、以及开箱即用的IDE工具链支持,为开发者打造友好开发体验和卓越程序性能。其具体特性表现为:
-
高效编程:面向应用开发,我们希望语言能够易学易用,降低开发者入门门槛和开发过程中的心智负担,支持各种常见的开发范式和编程模式,让开发者简洁高效地表达各种业务逻辑。仓颉是一门多范式编程语言,支持函数式、命令式和面向对象等多种范式,包括值类型、类和接口、泛型、代数数据类型、模式匹配、以及高阶函数等特性。此外,仓颉还支持类型推断,能够减轻开发者类型标注的负担;通过一系列简明高效的语法,能够减少冗余书写、提升开发效率;语言内置的各种语法糖和宏(macro)的能力,支持开发者基于仓颉快速开发领域专用语言(DSL),构建领域抽象。
-
安全可靠:作为现代编程语言,仓颉追求编码即安全,通过静态类型系统和自动内存管理,确保程序的类型安全和null safety等内存安全;同时,仓颉还提供各种运行时检查,包括数组下标越界检查、类型转换检查、数值计算溢出检查、以及字符串编码合法性检查等,能够及时发现程序运行中的错误;此外,还通过代码扫描工具、混淆工具以及消毒器,进一步提供跨语言互操作安全和代码资产保护等支持。
-
轻松并发:并发和异步编程能够有效提高处理器利用率,并在交互式应用中确保程序的响应速度,是应用开发中必不可少的能力。仓颉语言实现了轻量化用户态线程和并发对象库,让高效并发变得轻松。
仓颉语言采用用户态线程模型,每个仓颉线程都是极其轻量级的执行实体,拥有独立的执行上下文但共享内存。对开发者来说,用户态线程的使用和传统的系统线程的使用方式保持一致,没有带来额外负担;而从运行态视角看,线程的管理由运行时完成,不依赖操作系统的线程管理,因此线程的创建、调度和销毁等操作更加高效,且资源占用比系统线程更少。为了避免数据竞争,仓颉语言提供了并发对象库,并发对象的方法是线程安全的,因此在多线程中调用这些方法和串行编程没有区别,应用逻辑的开发者无需额外关心并发管理。对于一些核心库,仓颉还提供了无锁或者细粒度锁的算法实现,能够进一步减少线程的阻塞,提升并发度。 -
卓越性能:仓颉编译器及运行时从全栈对编译进行优化,包括编译器前端基于CHIR(Cangjie HighLevel IR)高层编译优化(比如语义感知的循环优化、语义感知的后端协同优化等),基于后端的编译优化(比如:SLP向量化、Intrinsic优化、InlineCache、过程间指针优化、Barrier优化等),基于运行时的优化(比如轻量锁、分布式标记、并发Tracing优化等),一系列的优化让仓颉充分发挥处理器能力,为应用提供卓越的性能支持。另外仓颉语言对运行时进行原生的轻量化设计,通过对运行时模块化分层设计,定义仓颉公共对象模型和运行时公共基础组件,基于公共对象模型,实现运行时的内存管理、回栈、异常处理、跨语言调用等基础能力,大幅减少多个能力间的冗余对象设计,精简运行时体积。同时通过包的按需加载技术,减少仓颉应用启动的冗余包内存开销,因此对于资源敏感设备,占用资源更少,支持更友好。
除此之外,仓颉还支持面向应用开发的一系列工具链,包括语言服务(高亮、联想)、调试(跨语言调试、线程级可视化调试)、静态检查、性能分析、包管理、文档生成、Mock工具、测试框架、覆盖率工具、Fuzz工具以及智能辅助编程工具,进一步提升软件开发体验以及效率。以下我们将围绕上述几个方面介绍仓颉语言的主要特性,让读者能够快速了解仓颉语言的定位和主要技术特色。
1、高效编程
1.1 多范式
仓颉是一个典型的多范式编程语言,对过程式编程、面向对象编程和函数式编程都提供了良好的支持,包括值类型、类和接口、泛型、代数数据类型和模式匹配,以及函数作为一等公民等特性支持。
1.1.1 类和接口
仓颉支持使用传统的类(class
)和接口(interface
)来实现面向对象范式编程。仓颉语言只允许单继承,每个类只能有一个父类,但可以实现多个接口。每个类都是Object
的子类(直接子类或者间接子类)。此外,所有的仓颉类型(包括Object)都隐式的实现Any接口。
仓颉提供open
修饰符,来控制一个类能不能被继承,或者一个对象成员函数能不能被子类重写(override
)。
在下面的例子中,类B继承了类A,且同时实现了接口I1和I2。为了让A能够被继承,它的声明需要被open
修饰。类A中的函数f也被open
修饰,因此可以在B中被重写。对函数f的调用会根据对象具体的类型来决定执行哪个版本,即动态派遣。
open class A {
let x: Int = 1
var y: Int = 2
open func f(){
println("function f in A")
}
func g(){
println("function g in A")
}
}
interface I1 {
func h1()
}
interface I2 {
func h2()
}
class B <: A & I1 & I2 {
override func f(){
println("function f in B")
}
func h1(){
println("function h1 in B")
}
func h2(){
println("function h2 in B")
}
}
main() {
let o1: I1 = B()
let o2: A = A()
let o3: A = B()
o1.h1() // "function h1 in B"
o2.f() // "function f in A"
o3.f() // 动态派遣,"function f in B"
o3.g() // "function g in A"
}
仓颉的interface
之间也可以继承,并且不受单继承的约束,即一个interfac
e也可以继承多个父 interface
。如下示例,I3可以同时继承I1和I2。因此,若要实现I3,需要提供对f、g和h三个函数的实现。
interface I1 {
func f(x: Int): Unit
}
interface I2 {
func g(x: Int): Int
}
interface I3 <: I1 & I2 {
func h(): Unit
}
1.1.2 函数作为一等公民
仓颉中函数可以作为普通表达式使用,可以作为参数传递,作为函数返回值,被保存在其他数据结构中,或者赋值给一个变量使用。
func f(x: Int) {
return x
}
let a = f
let square = {x: Int => x * x} // lambda 表达式
// 函数嵌套定义,以及函数作为返回值
func g(x: Int) {
func h(){
return f(square(x))
}
return h
}
func h(f: ()->Unit) {
f()
}
let b = h(g(100))
除了上面例子中的全局函数,对象或结构体等数据类型的成员函数同样也可以作为一等公民使用。下面的例子中,对象o的成员函数resetX作为普通表达式被赋值给变量f,对f的调用则会改变对象o中成员变量x的值。
class C{
var x = 100
func resetX(n: Int){
x = n
return x
}
}
main(){
let o = C()
let f = o.resetX // 成员函数作为一等公民
f(200)
print(o.x) // 200
}
1.1.3 代数数据类型和模式匹配
代数数据类型
是一种复合类型,指由其它数据类型组合而成的类型。两类常见的代数类型是积类型(如struct、tuple等)与和类型(如tagged union)。
在此我们着重介绍仓颉的和类型enum
,以及对应的模式匹配能力。
在下面的例子中,enum
类型BinaryTree具有两个构造器,Node和Empty。其中Empty不带参数,对应于只有一个空节点的二叉树,而Node需要三个参数来构造出一个具有一个值和左右子树的二叉树。
enum BinaryTree {
| Node(value: Int, left: BinaryTree, right: BinaryTree)
| Empty
}
访问这些enum
实例的值需要使用模式匹配
进行解析。模式匹配是一种测试表达式是否具有特定特征的方法,在仓颉中主要提供了match
表达式来完成这个目标。对于给定的enum
类型的表达式,我们使用match表达式来判断它是用哪个构造器构造的,并提取相应构造器的参数。下面的例子中,递归函数sumBinaryTree实现对二叉树节点中保存的整数求和。
func sumBinaryTree(bt: BinaryTree) {
match (bt) {
case Node(v, l, r) =>
v + sumBinaryTree(l) + sumBinaryTree(r)
case Empty => 0
}
}
除此enum
模式以外,仓颉也提供了其它各种模式,如常量模式、绑定模式、类型模式等,以及各种模式的嵌套使用。在下面的例子中,我们给出了对应模式的使用:
- 常量模式:可以使用多种字面量值进行判等比较,如整数、字符串等。
- 绑定模式:可以将指定位置的成员绑定到新的变量,多用于解构 enum 或 tuple。上面的sumBinaryTree例子中就用到了绑定模式,将Node节点中实际的参数与三个新声明的变量v、l和r分别绑定。
- 类型模式:可以用于匹配是否目标类型,多用于向下转型。
- tuple模式:用于比较或者解构tuple。
- 通配符模式:用于匹配任何值。
未来仓颉还计划引入更加丰富的模式,如序列(sequence)模式、record模式等。
// 常量模式-字符串字面量
func f1(x: String) {
match (x) {
case "abc" => ()
case "def" => ()
case _ => () // 通配符模式
}
}
// tuple 模式
func f2(x: (Int, Int)) {
match (x) {
case (_, 0) => 0 // 通配符模式和常量模式
case (i, j) => i / j // 绑定模式,将 x 的元素绑定到 i 和 j 两个变量
}
}
// 类型模式
func f3(x: ParentClass) {
match (x) {
case y: ChildClass1 => ...
case y: ChildClass2 => ...
case _ => ...
}
}
1.1.4 泛型
在现代软件开发中,泛型编程已成为提高代码质量、复用性和灵活性的关键技术。泛型作为一种参数化多态技术,允许开发者在定义类型或函数时使用类型作为参数,从而创建可适用于多种数据类型的通用代码结构。泛型带来的好处包括:
- 代码复用:能够定义可操作多种类型的通用算法和数据结构,减少代码冗余。
- 类型安全:支持更多的编译时的类型检查,避免了运行时类型错误,增强了程序的稳定性。
- 性能提升:由于避免了不必要的类型转换,泛型还可以提高程序执行效率。
仓颉支持泛型编程,诸如函数、struct
、class
、interface
、extend
都可以引入泛型变元以实现功能的泛型化。数组类型在仓颉中就是典型的泛型类型应用,其语法表示为Array<T>
,其中 T 表示了元素的类型,可以被实例化为任何一个具体的类型,例如Array<Int>
或Array<String>
,甚至可以是嵌套数组Array<Array<Int>>
,从而可以轻易地构造各种不同元素类型的数组。
除了类型外,我们还可以定义泛型函数。例如我们可以为使用泛型函数来实现任意两个同类型数组的 concat 操作。如下代码所示,我们定义了一个泛型函数 concat,并且它支持任意两个 Array 类型的数组参数,经过处理后返回了一个拼接后的新数组。这样定义的 concat 函数可以应用在 Array、Array、Array<Array> 以及其它任意类型的数组上,实现了功能的通用化。
func concat<T>(lhs: Array<T>, rhs: Array<T>): Array<T> {
let defaultValue = if (lhs.size > 0) {
lhs[0]
} else if (rhs.size > 0) {
rhs[0]
} else {
return []
}
let newArr = Array<T>(lhs.size + rhs.size, item: defaultValue)
// 使用数组切片进行整段拷贝
newArr[0..lhs.size] = lhs
newArr[lhs.size..rhs.size] = rhs
return newArr
}
泛型和接口以及子类型结合使用,还可以让我们对泛型中的类型变元给出具体的约束,从而对可以实例化该类型变元的实际类型做出限制。下面的例子中,我们希望在数组arr查找元素element。虽然我们并不关心数组及其元素的具体类型,但元素类型T必须能够支持判等操作,让我们能够比较数组中的元素与给定元素是否相等。因此,在lookup
函数中的where
子句中,我们要求T <: Equatable<T>
,即类型T必须实现了接口Equatable<T>
。
func lookup<T>(element: T, arr: Array<T>): Bool where T <: Equatable<T> {
for (e in arr){
if (element == e){
return true
}
}
return false
}
仓颉的泛型类型不支持协变。以数组为例,不同元素类型的数组是完全不相同的类型,它们之间不能互相赋值,哪怕元素类型之间具有父子类型关系也是禁止的。这避免了数组协变导致的类型不安全问题。
如下示例所示,Apple 是 Fruit 的子类,但是变量 a 和变量 b 之间是不能互相赋值的,Array 和 Array 之间没有子类型关系。
如下示例所示,Apple 是 Fruit 的子类,但是变量 a 和变量 b 之间是不能互相赋值的,Array 和 Array 之间没有子类型关系。
open class Fruit {}
class Apple <: Fruit {}
main() {
var a: Array<Fruit> = []
var b: Array<Apple> = []
a = b // 编译报错
b = a // 编译报错
}
1.2 类型扩展
仓颉支持类型扩展特性,允许我们在不改变原有类型定义代码的情况下,为类型增加成员函数等功能。具体来说,
仓颉的类型扩展可以对已有的类型做如下几类扩展:
- 添加函数
- 添加属性
- 添加操作符重载
- 实现接口
下面的例子中,我们为String
类型增加了printSize
成员函数,因此在下面的代码中就可以像调用其他预定义的成员函数一样来调用printSize
。
extend String {
func printSize() {
print(this.size)
}
}
"123".printSize() // 3
而当扩展和接口搭配使用的时候,它更能大幅提升语言的表达能力,我们甚至可以给已有的类型添加新的继承体系。
在下面的例子中,我们可以定义一个新接口Integer,然后用extend给已有的整数类型实现 Integer 接口,这样已有的整数类型就自然成为了 Integer 的子类型。其中sealed
修饰符表示该接口只能在当前包中被实现(或扩展)。
sealed interface Integer {}
extend Int8 <: Integer {}
extend Int16 <: Integer {}
extend Int32 <: Integer {}
extend Int64 <: Integer {}
let a: Integer = 123 // ok
1.3 类型推断
类型推断是指由编译器根据程序上下文自动推断变量或表达式的类型,而无需开发者显式写出。
仓颉作为现代编程语言,对类型推断也提供了良好的支持。
在仓颉中变量的定义可以根据初始化表达式的类型来推断其类型。除了变量以外,仓颉还额外支持了函数定义返回值类型的推断。在仓颉中,函数体的最后一个表达式会被视为这个函数的返回值
。像变量一样,当函数定义省略了返回类型,函数就会通过返回值来推断返回类型。
var foo = 123 // foo 是 'Int64'
var bar = 'hello' // bar 是 'String'
func add(a: Int, b: Int) { // add 返回 Int
a + b
}
仓颉还支持在泛型函数调用中推断类型参数,包括对柯里化函数里泛型参数的推断,如下面的例子所示:
func map<T, R>(f: (T)->R): (Array<T>)->Array<R> {
...
}
map({ i => i.toString() })([1, 2, 3]) // 支持推断泛型柯里化函数
// 推断结果为map<Int, String>({ i => i.toString() })([1, 2, 3])
注意lambda表达式作为map的第一个参数,它的参数类型(T)和返回值类型(R)都可以被推断出来,尽管参数类型(T)的推断还反过来依赖对map的第二个参数的类型(Array)的推断。
柯里化函数(Currying),什么是柯里化,为什么要进行柯里化,高级柯里化函数的实现
柯里化是一种函数的转换,它是指将一个函数从可调用的
f(a, b, c)
转换为可调用的f(a)(b)(c)
。
1.4 其他现代特性及语法糖
1.4.1 函数重载
仓颉允许在同一作用域内定义多个同名函数。编译器根据参数的个数和类型,来决定函数调用实际执行的是哪个函数
。例如,下面的绝对值函数,为每种数值类型都提供了对应的实现,但这些实现都具有相同的函数名abs,从而让函数调用更加简单。
func abs(x: Int64): Int64 { ... }
func abs(x: Int32): Int32 { ... }
func abs(x: Int16): Int16 { ... }
...
1.4.2 命名参数
命名参数是指在调用函数时,提供实参表达式的同时,还需要同时提供对应形参的名字。使用命名参数可以提升程序的可读性,减少参数的顺序依赖性,让程序更加易于扩展和维护。
在仓颉中,函数定义时通过在形参名后添加 !
来定义命名参数。当形参被定义为命名参数后,调用这个函数时就必须在实参值前指定参数名,如下面的例子所示:
func dateOf(year!: Int, month!: Int, dayOfMonth!: Int) {...}
dateOf(year: 2024, month: 6, dayOfMonth: 21)
1.4.3 参数默认值
仓颉的函数定义中,可以为特定形参提供默认值。函数调用时,如果选择使用该默认值做实参,则可以省略该参数。
这个特性可以减少很多函数重载或者引入建造者模式的需求,降低代码复杂度。
func dateOf(year!: Int64, month!: Int64, dayOfMonth!: Int64, timeZone!: TimeZone = TimeZone.Local) {
...
}
dateOf(year: 2024, month: 6, dayOfMonth: 21) // ok
dateOf(year: 2024, month: 6, dayOfMonth: 21, timeZone: TimeZone.UTC) // ok
1.4.4 尾随lambda(trailing lambda)
仓颉支持尾随lambda语法糖,从而更易于DSL中实现特定语法。具体来说,很多语言中都内置提供了如下经典的条件判断或者循环代码块:
if(x > 0){
x = -x
}
while(x > 0){
x--
}
尾随lambda则能够让DSL开发者定制出类似的代码块语法,而无需在宿主语言中内置。例如,在仓颉中,我们支持下面这种方式的函数调用:
func unless(condition: Bool, f: ()->Unit) {
if(!condition) {
f()
}
}
int a = f(...)
unless(a > 0) {
print("no greater than 0")
}
这里对unless函数的调用看上去像是一种特殊的if
表达式,这种语法效果是通过尾随lambda语法实现 —— 如果函数的最后一个形参是函数类型,那么实际调用这个函数时,我们可以提供一个lambda表达式作为实参,并且把它写在函数调用括号的外面。尤其当这个lambda表达式为无参函数时,我们允许省略lambda表达式中的双箭头=>,将其表示为代码块的形式,从而进一步减少对应DSL中的语法噪音。因此,在上面的例子中,unless调用的第二个实参就变成了这样的lambda表达式
{ print("no greater than 0") }
如果函数定义只有一个参数,并且该参数是函数类型,我们使用尾随lambda调用该函数时还可以进一步省略函数调用的括号,从而让代码看上去更简洁自然。
func runLater(fn:()->Unit) {
sleep(5 * Duration.Second)
fn()
}
runLater() { // ok
println("I am later")
}
runLater { // 可以进一步省略括号
println("I am later")
}
1.4.5 管道(Pipeline)操作符
仓颉中引入管道(Pipeline)操作符,来简化嵌套函数调用的语法,更直观的表达数据流向。下面的例子中,给出了嵌套函数调用和与之等效的基于管道操作符|>
的表达式。后者更加直观的反映了数据的流向:|>
左侧的表达式的值被作为参数传递给右侧的函数。
func double(a: Int) {
a * 2
}
func increment(a: Int) {
a + 1
}
double(increment(double(double(5)))) // 42
5 |> double |> double |> increment |> double // 42
1.4.6 操作符重载
仓颉中定义了一系列使用特殊符号表示的操作符
,其中大多数操作符都允许被重载,从而可以作用在开发者自己定义的类型上,为自定义类型的操作提供更加简洁直观的语法表达。
在仓颉中只需要定义操作符重载函数就能实现操作符重载。在下面的例子中,我们首先定义一个类型Point表示二维平面中的点,然后我们通过重载+操作符,来定义两个点上的加法操作。
struct Point {
let x: Int
let y: Int
init(x: Int, y: Int) {...}
operator func +(rhs: Point): Point {
return Point(
this.x + rhs.x,
this.y + rhs.y
)
}
}
let a: Point = ...
let b: Point = ...
let c = a + b
1.4.7 属性(property)
在面向对象范式中,我们常常会将成员变量设计为private的,而将成员变量的访问封装成getter和setter两种public方法。
这样可以隐藏数据访问的细节,从而更容易实现访问控制、数据监控、跟踪调试、数据绑定等业务策略。
仓颉中直接提供了属性这一种特殊的语法,它使用起来就像成员变量一样可以访问和赋值,但内部提供了getter和setter来实现更丰富的数据操作。对成员变量的访问和赋值会被编译器翻译为对相应getter和setter成员函数的调用
。
具体来说,prop
用于声明只读属性,只读属性只具有 getter 的能力,必须提供 get 实现;mut prop
用于声明可变属性。可变属性同时具备 getter 和 setter 的能力,必须提供 get 和 set 实现。
如下示例所示,开发者希望对 Point
类型的各数据成员的访问进行记录,则可以在内部声明 private
修饰的成员变量,通过声明对应的属性来对外暴露访问能力,并在访问的时候使用日志系统Logger
记录它们的访问信息。对使用者来说,使用对象p的属性与访问它的成员变量一样,但内部却实现了记录的功能。
注意这里x和y是只读的,只有get
实现,而color则是可变的,用mut prop
修饰,同时具有get和set实现。
class Point {
private let _x: Int
private let _y: Int
private var _color: String
init(x: Int, y: Int, color: String) {...}
prop x: Int {
get() {
Logger.log(level: Debug, "access x")
return _x
}
}
prop y: Int {
get() {
Logger.log(level: Debug, "access y")
return _y
}
}
mut prop color: String {
get() {
Logger.log(level: Debug, "access color")
return _color
}
set(c) {
Logger.log(level: Debug, "reset color to ${c}")
color = c
}
}
}
main() {
let p = Point(0, 0, "red")
let x = p.x // "access x"
let y = p.y // "access y"
p.color = "green" // "reset color to green"
}
2、安全可靠
2.1 静态类型和垃圾收集
仓颉是静态类型语言,程序中所有变量和表达式的类型都是在编译期确定的,并且在程序运行过程中不会发生改变。相比动态类型系统,静态类型系统对开发者有更多的约束,但能够在编译期尽量早的发现程序中的错误,提高程序的安全性,同时也让程序的行为更加容易预测,为编译优化提供了更多信息,使能更多的编译优化,提升程序的性能。
垃圾收集(GC)是一种自动内存管理机制,它能够自动识别和回收不再需要使用的对象,将开发者从手工释放内存中解放出来,不仅可以提高开发效率,还能有效避免各种常见内存错误,提升程序的安全性。常用的垃圾收集技术包括tracing和引用计数(reference counting,即RC)。仓颉采用tracing GC技术,通过在运行时跟踪对象之间的引用关系,来识别活动对象和垃圾对象。
2.2 空引用安全
空引用是指引用类型的值可以为 null
。代码存在空引用会引发各种各样潜在的风险,空引用被图灵奖得主Tony Hoare称为“价值十亿美元的错误”。
在许多编程语言中,空引用都是最常见的陷阱之一,开发者很容易在未确保非空的情况下访问引用类型的成员,从而引发错误或异常。因为语言类型系统并未给非空引用类型提供任何保障。
空引用安全就是旨在消除代码空引用危险。
仓颉是实现了空引用安全的语言之一。在仓颉中,没有提供 null 值,换句话说,仓颉的引用类型永远是非空的。从而在类型上杜绝了空引用的发生。
值得注意的是,表示一个空值在语义中是十分有用的。在仓颉中,对于任意类型T
,都可以有对应的可选类型Option<T>
。具有Option<T>
类型的变量要么对应一个实际的具有T
类型的值v,因此取值为Some(v)
,要么具有空值,取值为None
。
可选类型(Option<T>
)是一种 enum
类型,是一个经典的代数数据类型,表示有值或空值两种状态。
enum Option<T> {
Some(T) | None
}
var a: Option<Int> = Some(123)
a = None
注意Option<T>
和T
是两个不同的类型,具有两种类型的值之间不能互相转换 —— 给定一个Option<T>
类型的表达式e,我们只有通过模式匹配确定其值为Some(v)
时(也就是说其值非空),才可以得到一个T类型的值v
。因此,对表达式e的任意有意义的处理,必需伴随着模式匹配和对应的判空操作,从而不可能直接对空值None做解引用,避免了空引用异常。
基于可选类型使用的广泛性,仓颉还为可选类型提供了丰富的语法糖支持。例如可以使用 ?T
来代替 Option<T>
,也提供了可选链操作符(?.
)来简化成员访问,以及空合并操作符(??
)来合并有效值。
var a: ?Int = None
a?.toString() // None
a ?? 123 // 123
a = Some(321)
a?.toString() // Some("321")
a ?? 123 // 321
2.3 值类型
值类型是一种具有传递即复制的语义行为的类型,具有值类型的变量,其中保存的是数据自身,而不是指向数据的引用。由于值类型的这种特性,开发者选择性地使用值类型可以使得程序显著减少修改语义,从而让程序变得更可预测、更可靠。
例如最典型的并发安全问题就是在程序不同的线程中传递了同一个可变对象,此时访问这个对象的字段将会造成不可预测的 data race 问题。如果这个对象具备值语义,那么在传递的过程中我们就可以保证它经过了完整的复制,让每个线程对该值的访问都是彼此独立的,从而保证了并发安全。
仓颉原生支持了值类型,除了常见的 Int 类型以外,仓颉也可以使用 struct
来实现用户自定义的值类型。
如下面的例子,Point 正是一个值类型,因此在经过赋值后,a 和 b 已经是两个彼此独立的变量,对 a 的修改不会影响到 b。
struct Point {
var x: Int
var y: Int
init(x: Int, y: Int) { ... }
...
}
var a = Point(0, 0)
var b = a
a.x = 1
print(b.x) // 0
2.4 “不可变”优先
不可变(Immutable)指的是在变量赋值或对象创建结束之后,使用者就不能再改变它的值或状态。不可变意味着只读不写,因此不可变对象天然地具备线程安全的特性
,即如无其它特殊限制的话可以在任何线程上自由调用。此外,相较于可变对象,不可变对象的访问没有副作用,因此在一些场合下也会让程序更易于了解,而且提供较高的安全性。
不可变通常可以分为两种,一种是不可变变量
,不可变变量是指经初始化后其值就不可被修改的变量;另一种是不可变类型
,不可变类型是指在构造完成后实际数据对象的内容无法被改变。
在仓颉中,let定义的变量是不可变变量,而像 String、enum 等类型是不可变类型,这些都是不可变思想在仓颉中的应用。更多地使用不可变特性可以让程序更安全,也更利于理解和维护。
在仓颉中,let定义的变量是不可变变量,而像 String、enum 等类型是不可变类型,这些都是不可变思想在仓颉中的应用。更多地使用不可变特性可以让程序更安全,也更利于理解和维护。
2.4.1 函数参数不可变
在仓颉中,所有函数形参都是不可变的,这意味着我们无法对形参赋值,如果形参是值类型,也无法修改形参的成员。
struct Point {
var x: Int
var y: Int
init(x: Int, y: Int) { ... }
...
}
func f(a: Point) { // a 不可变
a = Point(0, 0) // error
a.x = 2 // error
}
2.4.2 模式匹配引入的新变量不可变
在仓颉中,模式匹配支持变量绑定模式,我们可以将目标值解析到新绑定的变量中,但这个变量仍然是不可变的。这意味着我们无法对绑定的变量赋值,如果变量是值类型,也无法修改变量的成员。
func f(a: ?Point) {
match (a) {
case Some(b) => //b 不可变
b = Point(0, 0) // error
b.x = 2 // error
case None =>
()
}
}
2.4.3 闭包捕获可变变量不允许逃逸
在仓颉中,闭包指的自包含的函数或 lambda
,闭包可以从定义它的静态作用域中捕获变量,即使对闭包调用不在定义的作用域,仍可以访问其捕获的变量。
仓颉中允许闭包捕获可变变量,但不允许该闭包继续逃逸,这避免了对可变变量修改可能导致的意外行为。
func f() {
let a = 1
var b = 2
func g() {
print(a) // ok
print(b) // ok
}
return g // error, g 捕获了可变变量 b,g 不允许作为表达式使用。
}
2.4.4 默认封闭
仓颉虽然支持了完整的面向对象范式,支持了类继承的特性,但仓颉并不鼓励滥用继承,尤其是默认可继承可覆盖。默认可继承语义会使得开发者设计的库无意间被使用者增加了抽象层次,提升了不必要的复杂性,从而引起一系列工程维护问题。
出于工程友好性的考虑,仓颉采取了默认封闭的设计选择,即类默认不可被继承,方法默认也不可被覆盖(override
)。开发者需要主动考虑是否需要自己的类型提供子类的能力,通过这样的约束减少了滥用继承的现象。
- 类默认不可继承
在仓颉中,开发者定义class时默认是不可继承的。如果希望该class有子类,必须显式使用open
、abstract
、sealed
其中一个修饰(这些修饰符的语义有细微差别,但都允许class被继承)。
class A {}
class B <: A {} // error,A 不允许被继承
open class C {}
class D <: C {} // ok
- 成员方法默认不可覆盖
在仓颉中,开发者定义成员方法默认是不可覆盖(override
)的。这意味着即使该类拥有子类,子类也无法修改该成员方法。如果希望一个成员方法可以被覆盖,必须显式使用open
修饰。
open class A {
func f() {}
open func g() {}
}
class B <: A {
override func f() {} // error,f 不允许被覆盖
override func g() {} // ok
}
2.5 try-with-resources
仓颉使用try-catch-finally
表达式来实现异常处理,该机制和传统语言的异常处理机制十分相似,但仓颉额外提供了try-with-resources
表达式语法来自动释放非内存资源。
不同于普通try
表达式,try-with-resources
表达式中的catch
块和finally
块均是可选的,并且try
关键字其后的块之间可以插入一个或者多个变量定义用来申请一系列的资源对象,这些资源对象在try-with-resources
表达式中会被自动管理起来,当某个资源发生异常或表达式结束后都会自动释放,达到安全管理资源的目的。
如下实例所示,input和output变量会在try-with-resources
表达式过程中自动管理,开发者不需要关注当中各种情况的资源释放问题。
try (input = MyResource(),
output = MyResource()) {
while (input.hasNextLine()) {
let lineString = input.readLine()
output.writeLine(lineString)
}
}
这里资源对象的类型(上面例子中的MyResource)必须已经实现了Resource
接口,特别是已经实现了Resource接口中要求的isClosed
和close
函数,能够判别资源是否已经被释放,以及做对应的释放操作。编译器将会在发生异常时或者try代码块正常结束时,插入对相应函数的调用,自动释放资源。
2.6 动态安全检查
除了静态类型给我们提供的安全保证以外,仓颉同时也非常重视运行时的安全检查,对于一些不适合使用静态类型的场景,仓颉也提供了运行时检查的安全保证。
2.6.1 溢出检查
不同于大多数传统语言,在仓颉中的整数运算默认会进行溢出检查而不是任由其 wrapping。
当上下文足以静态分析的时候,整数溢出可提前在编译期检测出来,编译器会直接给出报错;当上下文不足以静态分析的时候,整数溢出会在运行时做一个检查,如果溢出会抛出运行时异常。
这个机制使得大多数时候,整数溢出都会及时被感知,避免造成业务隐患。
运行时检查会增加额外的计算开销,但经过仓颉编译器优化后可以将计算开销控制在一个较小的水平。
如果一些敏感场景希望通过接受 wrapping 的代价来换取更好的极限性能,也可以手动指定溢出策略来实现传统语言的行为。
@OverflowWrapping
func test(x: Int8, y: Int8) { // if x equals to 127 and y equals to 3
let z = x + y // z equals to -126
}
2.6.2 数组越界检查
同样的,在仓颉数组的下标访问中,对数组的下标越界访问也有安全检查。当上下文足以静态分析的时候,下标访问可提前在编译期检测出来,编译器会直接给出报错;当上下文不足以静态分析的时候,下标访问会在运行时做一个检查,如果溢出会抛出运行时异常。
func f(index: Int) {
let a = [1, 2, 3]
let b = a[-1] // 编译期报错
let c = a[index] // 运行时检查
}
2.7 混淆
仓颉语言提供了多种混淆技术用于保护开发者的软件资产,提升攻击者逆向攻击仓颉软件的难度。攻击者可采用逆向工程技术对程序进行攻击,并获取程序的符号名、路径信息和行号信息、特征字符串和特征常数,以及控制流信息。仓颉混淆技术可以对这些信息进行混淆和隐藏,让攻击者难以借用这些信息辅助理解程序的运行逻辑。
-
外形混淆:外形混淆可以混淆仓颉应用的符号名、路径信息和行号信息,并且对仓颉二进制中的函数进行重排。混淆后,攻击者无法再利用这些信息辅助理解程序的运行逻辑。使能外形混淆后,函数名和变量名被随机字符串替换、路径名被字符串“SOURCE”替换、行号被修改为0。
-
数据混淆:仓颉编译器支持字符串混淆和常量混淆。字符串混淆功能会识别代码中的明文字符串,将其加密保存。程序在初始化时会先解密字符串,再执行程序逻辑。因此,外部攻击者无法直接从程序文件中获取明文字符串,只能看到加密后的数据,因此无法根据字符串信息猜测代码逻辑。对于程序中使用的已知常量,仓颉编译器支持使用常量混淆功能,将使用这些常量的代码片段转化为等价的、更加难以理解的代码片段。
-
控制流混淆:仓颉编译器支持虚假控制流和控制流平坦化两种控制流混淆功能,在不影响程序正常执行的前提下,打乱、重排基本块之间的跳转关系,从而提升分析理解程序控制流的难度。虚假控制流的原理是在程序中随机添加大量虚假的条件跳转分支,并且这些条件跳转分支的条件变量都是由不透明谓词组成;控制流平坦化的主要目的是隐藏基本块之间的跳转关系,并确保在实际执行时基本块的执行顺序和混淆前一致,保证程序的正常功能不被影响,但攻击者无法静态根据控制流信息得到基本块之间的先后执行关系以及跳转关系。
2.8 消毒器
消毒器是一种程序测试工具,通过插入检测代码来检测未定义或可疑行为形式的错误。仓颉支持多种类型的消毒器,仓颉在移动应用开发语言中率先支持基于硬件特性的地址消毒器。例如,使用直接映射的影子内存来检测内存损坏、缓冲区溢出或访问悬空指针(use-after-free)。
- 基于软件算法的仓颉CFFI内存安全检测机制(地址消毒器):仓颉语言的C语言互操作能力(CFFI)在与C/C++代码进行交互的过程中,由于其可以与C/C++代码进行不受限制的内存互访,C/C++侧安全漏洞可能影响整体安全。仓颉提供基于软件实现的CFFI内存安全检测机制,提供仓颉代码与C/C++代码交互过程中的内存安全检测能力,可以检测常见的空间内存安全问题(如堆、栈、全局变量溢出)和时间内存安全问题(如释放后使用、双重释放等)。
- 基于硬件特性的仓颉CFFI内存安全检测机制(硬件辅助的地址消毒器):基于硬件实现的仓颉CFFI内存安全检测利用处理器能力,实现更高效地检测仓颉代码与C/C++代码交互过程中的内存安全问题。相对于前述的软件CFFI内存安全检测机制,该机制可以检测更多内存安全问题,并且运行开销更低。
- 仓颉数据竞争安全检测(线程消毒器):仓颉定义的数据竞争指两个协程对同一个数据访问,其中至少有一个是写操作,而且这两个操作之间没有happens-before关系。仓颉数据竞争安全检测使用happens-before和lock-set算法检测数据竞争问题。开发者可以通过仓颉编译器提供的sanitize=thread选项使能该能力。
3、轻松并发
仓颉语言为并发编程提供了一种简单灵活的方式,通过轻量化线程模型
和高效易用的无锁并发对象
让并发编程变得轻松,将高效并发处理的能力直接置于开发者的手中。这一节将详细介绍仓颉并发编程两大关键技术的核心思想、设计、以及带来的显著优势,揭示仓颉语言如何实现“轻松并发”。
3.1 轻量化线程模型
仓颉语言采用用户态线程模型
,在该模型下,每个仓颉线程都是极其轻量级的执行实体,拥有独立的执行上下文但共享内存。该模型不仅简化了开发者编写并发代码的过程,还带来了显著的性能优势。
- 开发态:仓颉语言的线程模型使开发者能够像编写普通代码一样轻松地实现并发编程。通常,用户态线程模型可分为“无栈”和“有栈”两种实现方案。尽管“无栈”模型可以将内存占用降到极低,但其实现通常需要在语言中引入新语法,最常见的就是
async/await
关键字。然而,这种新语法会显著增加开发者编写并发代码的复杂度。开发者不仅需要在编程过程中手动标记(如用async
标记异步函数并用await
标记其调用点),而且这种标记具有“传染性”(包含await
的函数必须标记为async
),导致经典的“函数染色”问题。仓颉线程拥有独立的执行上下文,因此能够自由切换,开发者无需为标记操心,从而彻底消除这一复杂性。 - 运行态:与传统的操作系统线程相比,轻量化线程模型在性能上具有明显优势。由于其实现完全在用户空间进行,不依赖操作系统的线程管理,这从根本上减少了线程创建和销毁的开销,同时简化了线程调度流程。仓颉语言通过这种设计,实现了更高效的资源利用和更快的执行速度,尤其是在高并发场景下,这种优势更为显著。在一台常见的 x86 服务器上,仓颉线程创建的平均耗时为 700ns,这远小于操作系统线程的创建开销(操作系统线程的创建耗时量级一般为百微妙)。此外,一个仓颉线程仅占用 8Kb 内存资源,因此开发者可以在一个程序中同时创建十万级数量的仓颉线程,大大超出操作系统线程的限制。
整体而言,仓颉语言的轻量化线程设计不仅降低系统的负担,而且使得开发者能够在不增加编程复杂度的前提下,轻松实现数千甚至数万个并发任务
。其核心优势包括:
- 简单的并发编程:不对开发者编写并发代码做过多语法约束,使其方便地使用仓颉线程并专注业务处理。
- 轻量级的开销:由于创建和切换用户态线程的开销远远小于传统的内核线程,仓颉语言可以快速地创建和销毁大量用户态线程,使得开发高并发应用变得轻而易举。
- 更高的并发能力:仓颉语言通过用户态线程模型,可以实现非常高的并发数,这使得它特别适合于I/O密集型和高并发的网络服务场景。
- 减少上下文切换成本:在轻量化线程模型中,上下文切换发生在用户空间,避免了传统线程切换需要经过内核态和用户态之间频繁转换的高成本。
基于这样的设计,在仓颉语言中,实现高效并发不再是一项复杂且耗时的任务。开发者可以通过简单的语法构造大量的用户态线程,无需担心传统并发模型中常见的性能瓶颈。假设我们有一个需求:需要同时处理多个网络请求。在仓颉语言中,这可以轻松实现,如下代码所示:
func fetch_data(url: String) {
let response = http_get(url)
process(response)
}
main() {
let urls = ["https://example.com/data1", "https://example.com/data2", ...]
let futures = ArrayList<Future<Unit>>()
for (url in urls) {
let fut = spawn { fetch_data(url) } // 创建仓颉线程进行网络请求
futures.append(fut)
}
for (fut in futures) { // 等待所有仓颉线程完成
fut.get()
}
}
在上述例子中,spawn
关键字用于创建一个新的仓颉线程,每个线程独立地执行 fetch_data 函数。仓颉语言的运行时环境会自动调度这些线程,而开发者只需关注业务逻辑的实现。最后通过获取线程结果来等待所有的仓颉线程完成,确保主线程能够同步地获取所有结果。
仓颉语言的用户态线程模型以其显著的性能优势和轻量级的设计理念,为并发编程提供了一个颇具吸引力的新选择。它使得编写高并发应用变得更加直接和高效,不仅适用于高负载的网络服务,还能满足各种计算密集型任务的需要。通过这种新型并发模型,仓颉语言降低了并发编程的复杂性,同时还充分利用了现代多核处理器的强大能力。
3.2 无锁并发对象
在多线程共享内存并发场景,程序员需要注意控制不同线程访问同一内存单元的顺序,否则可能产生“数据竞争”。一般语言通过提供互斥锁等特性来支持进程并发的访问共享内存。
然而让程序员自己来控制线程访问共享内存仍然是一件复杂且并发性能不高的事情:
例如上图,一块内存 M 多线程共享,用一个互斥锁同步对内存块 M 并发访问,开发效率、运行性能都不是最优。
那么是否能对内存 M 进行切分,以更细粒度的方式来加锁以提升性能,并且语言自动实现锁的保护而让开发者像单线程一样编写程序呢?
如下图:展示了细粒度并发控制,将内存块 M 划分为多个区域,不同线程可以并发访问不同区域。但细粒度并发算法复杂,并且在实际场景中,M 可能代表一个数据结构,对本就复杂的数据结构做细粒度的并发控制并不容易,很容易产生“数据竞争”或“不具有并发原子性”等并发问题。
为了解决该问题,仓颉提供了基于细粒度并发算法实现的并发对象,而用户通过调用并发对象的接口来操作多线程共享内存,从而实现:
- 为用户提供无锁编程体验:用户通过接口调用实现高效的共享内存并发访问。
- 为用户提供并发安全保障:仓颉并发对象的接口可保证无数据竞争,核心接口具有并发原子性。
- 提升性能:仓颉并发对象的设计使用细粒度并发算法。
- 保证并发原子性:仓颉并发对象的核心方法具有并发“原子性”,即从用户视角来看,该方法调用执行不会被其它线程打断。
下表展示了仓颉提供的多线程共享并发对象,并提供并发安全和并发性能的保障:
1.并发安全:
用户在并发场景调用原子类型和并发数据结构接口操作多线程共享对象不会产生“数据竞争”。原子类型为用户提供了并发场景下:整型、布尔型和引用类型的原子操作。并发数据结构的核心方法具有并发原子性,例如:ConcurrentHashMap
中的插入键值对 put,删除键值对 remove 和替换键值对 replace 等方法。并发场景下,用户可以将这些操作的调用执行视为原子的,不会被其它线程打断。
2.提升并发性能:
并发哈希表和并发队列基于上述介绍的细粒度并发算法实现,下图展示了仓颉并发哈希表 ConcurrentHashMap
与使用一把互斥锁控制多线程并发访问 HashMap
(粗粒度并发控制)的性能对比,其中,横坐标为线程数,纵坐标为每毫秒完成的并发哈希表操作数(并发哈希表操作中,put、remove 和 get 方法分别占 10%、10%、80%)。黄色线条为仓颉 ConcurrentHashMap 的测试数据,而蓝色线条则为粗粒度方法的测试数据,可见使用细粒度并发算法的仓颉并发哈希表性能相比粗粒度方法优势明显,且性能随着线程数的增加而提升。
下图是仓颉并发队列 BlockingQueue
在 single-producer & single-consumer 场景下与使用一把互斥锁控制多线程并发访问队列(粗粒度并发控制)的性能对比,我们分别测试了队列容量为 128 和 4096 的场景,纵坐标为每毫秒出入队元素的个数,仓颉 BlockingQueue 性能相比粗粒度方法优势明显。