编程语言中索引签名是什么?

编程语言中索引签名是什么?_第1张图片

1. 背景

最近在参与 KusionStack 内置的领域语言 —— KCL配置语言编译器 的开发,语言的语法中包括一个“索引签名”的概念,在参与社区讨论的时候发现很多小伙伴不明白这个“索引签名”是什么,于是自己也想了一下,发现自己也只是知道是什么样子,但是不知道“索引签名”完整的定义,因此,决定写一篇贴子来梳理一下“索引签名”到底是什么。

2.见名知意

首先,索引签名的想法并不神秘新鲜。早期Windows开发中应该见过类似的编程规范:

  • bool(BOOL) 用b开头 bIsParent
  • byte(BYTE) 用by开头 byFlag
  • short(int) 用n开头 nStepCount
  • long(LONG) 用l开头 lSum
  • char(CHAR) 用c开头 cCount

只要看到变量和类成员的名字就知道其类型,提高了代码类型方面的可读性。但是这种约定并没有沉淀到C++语言中,如果语言能够支持定义以b开头的成员是BOOL类型这种特性就厉害了——这其实就是索引签名的朴实目的。

先从字面意义上看,“索引签名(index signature)” 包含 “索引(index)” 和“签名(signature)”两部分。

2.1 索引(index)

从开发人员的角度来看,索引,类似于C语言中指针,它像一个箭头一样可以指向一个特定的事物,出于某些原因我们可能无法直接访问这个事物,或者这个事物与其他的东西混合在一起,直接访问这个事物可能要在很多其他事物中寻找很久。因此,我们使用索引指向这个事物,可以理解为我们在我们需要的事物上绑了一根线,并且留了一个线头在身边,每当我们想要使用这个事物的时候,我们不需要再从一堆事物中去寻找它,只需要拿起这个线头,并顺着这根线就能找到这个特定的事物,这个线头就是"索引 index",并且很明显,一根线不允许分叉绑定两个事物,所以通常大家默认某个事物的"索引 index"是不会再指向另一个事物的。

因此,在开发的过程中,“索引 index”最主要的使用场景就是“在一堆事物中,查找特定的事物”。例如:最常见的数据结构-数组,就是“索引”的一个优秀案例。在数组中,索引是一个整形的数字,这个数字是数组中每个元素的位置信息,通过位置,快速的定位某个数组元素。

int a[3] = [1, 2, 3];

// 使用索引0,就可以在1,2,3三个数字中,快速的找到排在最前面的元素。
assert a[0] = 1;
assert a[1] = 2;
assert a[3] = 3;

除了数组,另一个使用索引的数据结构是我们常见的Hash表,只不过在有些编程语言中将哈希表中的索引叫做key。

hashtable a;

// "Jack" 就可以视作一个索引,通过名字字符串作为索引,
// 在不考虑重名的情况下,它指向了一个结构实例 Person("Jack")。
a.put("Jack", new Person("Jack"))
a.put("Tom", new Person("Tom"))
a.put("Lee", new Person("Lee"))

再举一个例子,很多编程语言中都存在的结构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。
assert p.age == 10

综上,索引可以被看作一个指针,没有具体的格式约束,只要能唯一的指向一个事物即可,不能具有二义性,即不能指向A的同时又指向B。或者索引也可以看作一个方法,以索引值为参数,返回索引指向的事物。

注:这个概念不包括一些特殊情况,比如某些应用场景就是需要同时指向A和B的索引也是有可能的,这里讨论的是大多数的通用情况。

2.2 签名(Signature)

在编程语言领域,Signature这个词除了使用在IndexSignature中,在很多常见的编程语言中也有Signature这个概念。比如C++中的类型签名:

char c;
double d;

// 他的签名为 (int) (char, double)
int retVal = (*fPtr)(c, d);

通过上面这个类型签名,我们虽然不知道这个函数指针未来可能会指向的函数的具体定义,但是通过这个签名,我们能看到这个指针指向的函数如何使用,它以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 中的类型签名:

编程语言中索引签名是什么?_第2张图片

可以看到,核心的思想与C/C++/Rust中的类型签名一样,通过描述方法的传入参数与返回值的类型,来概述一个方法如何使用,而不需要关心这个方法的具体实现。

Python/Golang 中也有类型签名的概念,而且核心思路都一样,这里就不再赘述了。

通过了解这些编程语言的类型签名,我们知道,签名(Signature)其实与类型(Type)描述了同一个事物,类型(Type)所描述的事物是某些性质的集合,具备相同性质的事物,就可以认为它们的类型(Type)相同;而签名(Signature)可以看作由多个类型(Type)组合而成的复合类型。

举例说明:

int32 a = 0; // a 的类型 (type) 是 int32

可以看到上述变量 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)。那么,写到现在,通过类比,也能猜出索引签名大概是个什么东西了,前面提过索引可以看做是一个方法,输入一个值,返回它指向的事物。

2.3 索引签名(IndexSignature)

上面提到,索引我们可以看作一个指针或是一个方法,签名(Signature)就可以理解为将多个类型(type)组合起来形成的复合类型,索引签名(IndexSignature)描述的就是索引的类型,写到这里我脑子里产生了点疑问,那索引签名不就是索引的类型吗,索引为什么要使用复合类型进行描述,一个普通类型(type)描述不了索引的类型吗?

  • a[0] 这个索引的类型不就是Integer吗 ?
  • hash.get("name") 这个索引的类型不就是String吗 ?

这个问题,源自于对索引理解的偏差,

  • a[0] 的索引不是0,他的索引是 0->a[0]`, 即输入0,返回 [0].
  • hash.get("name") 的索引也不是“name”,他的索引是 “name”->"Jack", 输入“name”返回"Jack"。

写到这里,其实使用各种编程语言的小伙伴们心里应该都能感觉自己可能或多或少都接触过索引签名这个东西,只是当时并不在意他叫什么,之所以这么说,是因为我自己在写到这里的时候,想到了之前开发java的时候使用的 hashmap:

public class RunoobTest {
    public static void main(String[] args) {
        HashMap Sites = new HashMap();

        Sites.put("one", "Google");
        Sites.put("two", "Runoob");
        Sites.put("three", "Taobao");
        Sites.put("four", "Zhihu");

        System.out.println(Sites);
    }
}

上述代码第7行HashMap Sites = new HashMap()中, 就可以理解为一种索引签名,它定义了这个HashMap结构中的索引的类型,是输入一个字符串,返回一个字符串。而数组的索引签名也类似,只不过,数组的索引签名编译器帮我们自动省略了,即输入类型一定是int,不用我们手动书写了。

// 显式索引签名:Array a = [0, 1, 2]
int[] a = [0, 1, 2];

// 显式索引签名:Array a = ["0", "1", "2"]
String[] a = ["0", "1"];

3. 一些语言中的索引签名

索引签名的思想由来已久,最早甚至可以追溯到早些年间程序员们为了程序的可读性而定下的编程规约,当我们规定一个整型变量的名称必须以i开头的时候,其实已经是在定义指向一个整型的的索引的签名了。

int i_user_id = 10; // 整型以i开头,定义了  的索引签名
float f_user_weight = 120.3; // 浮点以f开头,定义了  的索引签名

不过,规约可能并不是所有人都愿意遵守,当索引的名称成为编程元素的一部分,并且可以动态的操作的时候,将索引签名作为变成规约,就不是太合适了。

// 当出现可以动态添加索引的编程元素。
const a = {
    "name": "Jack"
}

// 你和你的小伙伴约定好,年龄的索引就是“age”。
// 他在某个地方add("age", 10)。
a.add("age", 10);

// 你在某个地方,需要这个年龄。
a.get("age");

// 如果索引签名是编程规约,而不带有强制性。
// 你的小伙伴恰恰手一滑,眼一闭,写错了也没看到 warning。
a.add("aeg", 10);

// 那你这边就只能看到空指针异常了。
NullPointerException: "age" is not define.

因此,为了提升程序的稳定性,避免这种不必要的风险,一些通用编程语言(如:TypeScript)和领域语言(如:KCL,CUE)开始将索引签名作为语言的特性暴露给开发者,旨在提供编程过程中的安全性和稳定性,降低上述问题产生的影响。

3.1 TypeScript 索引签名

在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,类型不匹配。
};

3.2 CUE索引签名

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" // 其他索引的返回值类型都是字符串。
}

3.3 KCL索引签名

KCL 索引签名的形式为 [: ]: ,语义上表示结构中所有属性的key只能为 类型,值只能为 类型

  • 索引签名的 ` 处的类型只能为 str, int, float,不能为 union 类型
  • 可以为KCL任意合法的数据类型,包含schema类型和union类型等类型
  • 表示任意KCL合法的标识符,并且可以省略不写,一般用于和check结合使用

基础用法

schema定义方式

schema Map:
    [str]: str
  • 注意使用了索引签名的schema默认为relaxed。
  • 一个索引签名只能在schema当中定义一次。

高级用法

类型签名书写默认值

schema Map:
    [str]: str = {"default_key": "default_value"}

与schema定义混合使用,强制schema所有属性key, value类型:

schema Person:
    name: str
    age: int  # error, 与[str]: str语义冲突,
    [str]: str  # schema所有属性的值只能为字符串类型

可以在 schema 中同时定义 schema 属性和索引签名,通常用于表示 schema 中额外属性的类型约束,强制除schema定义所有属性key, value类型。

schema Person:
    name: str
    age?: int
    [...str]: str  # 表示除name, age之外,其余schema属性必须为字符串类型,属性的值也必须为字符串类型

属性名称配合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"]
}
  • 注意:KCL索引签名暂不支持union类型以及字面量类型等类型。
  • 注意:索引签名暂不支持对value的的值进行校验,仅支持类型检查。
  • 注意:索引签名暂不支持类似CUE的正则校验["$b^"] ,因为属于runtime检查,不属于类型系统的一部分,当类型检查从runtime阶段前置后不容易结合,因此暂不支持。

4. 总结

本文简单介绍了索引签名,通过梳理索引和签名的概念,并对比了一些通用编程语言和领域语言中使用到的签名的思想,泛泛的描述了一下索引签名大概的样子,希望能够帮助大家能够更加轻松的了解索引签名这个概念,文章的内容只是笔者个人对索引签名的理解,如果有不对或者不合适的地方欢迎大家指正。

编程语言中索引签名是什么?_第3张图片

参考链接

你可能感兴趣的:(编程语言中索引签名是什么?)