最近在参与 KusionStack 内置的语言引签领域语言 —— KCL 配置语言编译器 的开发,语言的中索语法中包括一个 “索引签名” 的概念,在参与社区讨论的编程时候发现很多小伙伴不明白这个 “索引签名” 是什么,于是语言引签自己也想了一下,发现自己也只是中索知道是什么样子,但是编程不知道 “索引签名” 完整的定义,因此,语言引签决定写一篇贴子来梳理一下 “索引签名” 到底是中索什么。 首先,语言引签索引签名的中索想法并不神秘新鲜。早期 Windows 开发中应该见过类似的编程编程规范: 只要看到变量和类成员的名字就知道其类型,提高了代码类型方面的语言引签可读性。但是中索这种约定并没有沉淀到 C++ 语言中,如果语言能够支持定义以 b 开头的成员是亿华云 BOOL 类型这种特性就厉害了 —— 这其实就是索引签名的朴实目的。 先从字面意义上看,“索引签名 (index signature)” 包含 “索引 (index)” 和 “签名 (signature)” 两部分。 从开发人员的角度来看,索引,类似于 C 语言中指针,它像一个箭头一样可以指向一个特定的事物,出于某些原因我们可能无法直接访问这个事物,或者这个事物与其他的东西混合在一起,直接访问这个事物可能要在很多其他事物中寻找很久。因此,我们使用索引指向这个事物,可以理解为我们在我们需要的事物上绑了一根线,并且留了一个线头在身边,每当我们想要使用这个事物的时候,我们不需要再从一堆事物中去寻找它,只需要拿起这个线头,并顺着这根线就能找到这个特定的事物,这个线头就是 "索引 index",并且很明显,一根线不允许分叉绑定两个事物,所以通常大家默认某个事物的 "索引 index" 是服务器托管不会再指向另一个事物的。 因此,在开发的过程中,“索引 index” 最主要的使用场景就是 “在一堆事物中,查找特定的事物”。例如:最常见的数据结构 - 数组,就是 “索引” 的一个优秀案例。在数组中,索引是一个整形的数字,这个数字是数组中每个元素的位置信息,通过位置,快速的定位某个数组元素。 int a[3] = [1, 2, 3]; // 使用索引0,就可以在1,2,3三个数字中,快速的找到排在最前面的元素。 assert a[0] = 1; assert a[1] = 2; 除了数组,另一个使用索引的数据结构是高防服务器我们常见的 Hash 表,只不过在有些编程语言中将哈希表中的索引叫做 key。 hashtable // "Jack" 就可以视作一个索引,通过名字字符串作为索引, // 在不考虑重名的情况下,它指向了一个结构实例 Person("Jack")。 a.put("Jack", new Person("Jack")) a.put("Tom", new Person("Tom")) 再举一个例子,很多编程语言中都存在的结构 struct 或者类 class,也使用到了索引的思想。 // 可以看做是String和Integer的集合 // 如果没有索引,我们就只知道Person内部有两个属性, // 一个类型为String表示名字, // 一个为Integer表示年龄。 struct Person{ name: String, age: Integer, } Person p = new Person(name: "Jack", age: 10); // 通过索引name我们能够轻松的获取到Person的名字Jack。 assert p.name == "Jack" // 通过索引age我们能够轻松的获取到Person的年龄10。 综上,索引可以被看作一个指针,没有具体的格式约束,只要能唯一的指向一个事物即可,不能具有二义性,即不能指向 A 的同时又指向 B。或者索引也可以看作一个方法,以索引值为参数,返回索引指向的事物。 注:这个概念不包括一些特殊情况,比如某些应用场景就是需要同时指向 A 和 B 的索引也是有可能的,这里讨论的是大多数的通用情况。 在编程语言领域,Signature 这个词除了使用在 IndexSignature 中,在很多常见的编程语言中也有 Signature 这个概念。比如 C++ 中的类型签名: char c; double d; // 他的签名为 (int) (char, double) 通过上面这个类型签名,我们虽然不知道这个函数指针未来可能会指向的函数的具体定义,但是通过这个签名,我们能看到这个指针指向的函数如何使用,它以 char 和 double 为传入参数,返回值为 int,并且,这个签名也对指针未来指向的函数进行了约束,它只能指向以 char 和 double 为传入参数,返回值为 int 的函数。相似的概念在 Rust 语言中也有体现。在 Rust 中,我们可以直接使用一个函数的签名如下: // add 方法的签名 fn(i32, i32) -> i32 fn add(left: i32, right: i32) -> i32 { left + right } // sub 方法的签名 fn(i32, i32) -> i32 fn sub(left: i32, right: i32) -> i32 { left - right } // 通过方法签名,我们可以为某一类结构相近的方法提供工厂。 fn select(name: &str) -> fn(i32, i32) -> i32 { match name { "add" => add, "sub" => sub, _ => unimplemented!(), } } fn main() { let fun = select("add"); println!("{ } + { } = { }", 1, 2, fun(1, 2)); 再来看看 Java 中的类型签名: 可以看到,核心的思想与 C/C++/Rust 中的类型签名一样,通过描述方法的传入参数与返回值的类型,来概述一个方法如何使用,而不需要关心这个方法的具体实现。 Python/Golang 中也有类型签名的概念,而且核心思路都一样,这里就不再赘述了。 通过了解这些编程语言的类型签名,我们知道,签名 (Signature) 其实与类型 (Type) 描述了同一个事物,类型 (Type) 所描述的事物是某些性质的集合,具备相同性质的事物,就可以认为它们的类型 (Type) 相同;而签名 (Signature) 可以看作由多个类型 (Type) 组合而成的复合类型。 举例说明: 可以看到上述变量 a 的类型 (type) 是 int32,大家只要一听到 int32,就会条件反射的想到 a 的一些性质,比如:32 位,整数,范围等等,int32 就是对这些性质的总称,下次再遇到一个变量 b,只要他的性质符合 int32 的性质,我们就可以把它们归为一类,即,它们都是类型 (type) 为 int32 的变量。 可是,编程语言的类型系统中,不仅仅有变量,还有一个很重要的东西 -- 方法。 int add(int a, int b) { return a+b; 现在,就需要一个东西,描述上面这个方法的类型,即,需要有一个东西来区分什么样子的方法与 add 方法属于同一类。名称?恐怕不行,因为下面这两个同名方法用起来感觉完全不一样。 // 两个数相加 int add(int a, int b) { return a+b; } // 两个数组合并 int[] add(int a[], int b[]) { return a.append(b); 所以,在大佬们设计语言的的时候决定,使用返回值和参数列表的类型 (type) 组合起来,来定义一个方法的类型,即: // 两个数相加 // 类型为 (int, int) -> int int add(int a, int b) { return a+b; } // 两个数组合并 // 类型为 (int[], int[]) -> int[] int[] add(int a[], int b[]) { return a.append(b); 而签名 (Signature) 就可以理解为将多个类型 (type) 组合起来形成的复合类型。这个签名用来描述方法的类型,就可以叫做方法签名 (Method/Function Signature)。那么,写到现在,通过类比,也能猜出索引签名大概是个什么东西了,前面提过索引可以看做是一个方法,输入一个值,返回它指向的事物。 上面提到,索引我们可以看作一个指针或是一个方法,签名 (Signature) 就可以理解为将多个类型 (type) 组合起来形成的复合类型,索引签名 (IndexSignature) 描述的就是索引的类型,写到这里我脑子里产生了点疑问,那索引签名不就是索引的类型吗,索引为什么要使用复合类型进行描述,一个普通类型 (type) 描述不了索引的类型吗? 这个问题,源自于对索引理解的偏差, 写到这里,其实使用各种编程语言的小伙伴们心里应该都能感觉自己可能或多或少都接触过索引签名这个东西,只是当时并不在意他叫什么,之所以这么说,是因为我自己在写到这里的时候,想到了之前开发 java 的时候使用的 hashmap: public class RunoobTest { public static void main(String[] args) { HashMap Sites.put("one", "Google"); Sites.put("two", "Runoob"); Sites.put("three", "Taobao"); Sites.put("four", "Zhihu"); System.out.println(Sites); } 上述代码第 7 行 HashMap<string, string> Sites = new HashMap<string, string>() 中, <string, string > 就可以理解为一种索引签名,它定义了这个 HashMap 结构中的索引的类型,是输入一个字符串,返回一个字符串。而数组的索引签名也类似,只不过,数组的索引签名编译器帮我们自动省略了,即输入类型一定是 int,不用我们手动书写了。 // 显式索引签名:Array int[] a = [0, 1, 2]; // 显式索引签名:Array 索引签名的思想由来已久,最早甚至可以追溯到早些年间程序员们为了程序的可读性而定下的编程规约,当我们规定一个整型变量的名称必须以 i 开头的时候,其实已经是在定义指向一个整型的的索引的签名了。 int i_user_id = 10; // 整型以i开头,定义了 的索引签名 不过,规约可能并不是所有人都愿意遵守,当索引的名称成为编程元素的一部分,并且可以动态的操作的时候,将索引签名作为变成规约,就不是太合适了。 // 当出现可以动态添加索引的编程元素。 const a = { "name": "Jack" } // 你和你的小伙伴约定好,年龄的索引就是“age”。 // 他在某个地方add("age", 10)。 a.add("age", 10); // 你在某个地方,需要这个年龄。 a.get("age"); // 如果索引签名是编程规约,而不带有强制性。 // 你的小伙伴恰恰手一滑,眼一闭,写错了也没看到 warning。 a.add("aeg", 10); // 那你这边就只能看到空指针异常了。 因此,为了提升程序的稳定性,避免这种不必要的风险,一些通用编程语言 (如:TypeScript) 和领域语言 (如:KCL,CUE) 开始将索引签名作为语言的特性暴露给开发者,旨在提供编程过程中的安全性和稳定性,降低上述问题产生的影响。 在 TS 中,我们可以通过下面这种方式定义一个对象: const salary1 = { baseSalary: 100_000, yearlyBonus: 20_000 根据上文我们对索引的描述,我们知道这个对象有两个索引,并且它们的类型即索引签名应该是相同的,即它们是同一类索引。 const salary1 = { baseSalary: 100_000, // 索引1 : 输入“baseSalary”,返回100_000 yearlyBonus: 20_000 // 索引2 : 输入”yearlyBonus“, 返回20_000 TS 提供了一种特性,使得开发者可以编写这种索引签名: interface NumbersNames { [key: string]: string // 索引的类型为输入String,返回String } const names: NumbersNames = { 1: one, 2: two, 3: three, // etc... 5: five // Error: 这个索引的输入类型为int,类型不匹配。 CUE 支持在索引签名中写正则表达式,支持对索引名称的校验。 a: { foo: string // 索引foo 返回值是string类型。 [=~"^i"]: int // 以i开头的索引,返回值都是int。 [=~"^b"]: bool // 以b开头的索引,返回值都是bool。 ...string // 其他的所有的索引返回值都是string。 } b: a & { i3: 3 // 索引i3以i开头,返回值是3类型为int。 bar: true // 索引bar以b开头,返回值true类型为bool。 other: "a string" // 其他索引的返回值类型都是字符串。 KCL 索引签名的形式为 [<attr_name>: <index_type>]: <value_type> ,语义上表示结构中所有属性的 key 只能为 <index_type> 类型,值只能为 <value_type> 类型: schema 定义方式: schema Map: 注意使用了索引签名的 schema 默认为 relaxed。 一个索引签名只能在 schema 当中定义一次。 类型签名书写默认值: schema Map: 与 schema 定义混合使用,强制 schema 所有属性 key, value 类型: schema Person: name: str age: int # error, 与[str]: str语义冲突, 可以在 schema 中同时定义 schema 属性和索引签名,通常用于表示 schema 中额外属性的类型约束,强制除 schema 定义所有属性 key, value 类型。 schema Person: name: str age?: int 属性名称配合 check 使用: schema Data: [dataName: str]: str check: dataName in ["Alice", "Bob", "John"] data = Data { Alice: "10" Bob: "12" Jonn: "8" # error Jonn not in ["Alice", "Bob", "John"] 本文简单介绍了索引签名,通过梳理索引和签名的概念,并对比了一些通用编程语言和领域语言中使用到的签名的思想,泛泛的描述了一下索引签名大概的样子,希望能够帮助大家能够更加轻松的了解索引签名这个概念,文章的内容只是笔者个人对索引签名的理解,如果有不对或者不合适的地方欢迎大家指正。一、编程背景
二、编程见名知意
1. 索引 (index)
2. 签名 (Signature)
3. 索引签名 (IndexSignature)
三、一些语言中的索引签名
1. TypeScript 索引签名
四、总结