Telegram 之 TL Language

上一篇博文的最后提出,如果不出意外,这一篇就是用来介绍telegram的TL-Language,那么我的确也没出什么意外,所以这一篇就兑现这样的承诺吧!

TL-Language是telegram用来描述MTProto的一种自定义语言,不难看出,能将协议的描述定义成一门语言,这也足够说明了telegram的逼格。其存在的意义,类似基于SOAP协议的Web Service用来描述服务元数据的XML Scheme,但又有不同,目前的TL-Language仅仅只能用来作“描述”作用,最终通讯还是序列化成二进制流,并且也没有服务元数据这么一说。

这一次我们不管通讯,不管架构,只是单纯的看看这门语言吧!

基本语法

在进入高级主题之前,我们先看看它的基础语法,起码你要能够看懂它的一般表达形式嘛!我们看看下面这段TL-Language:

1
2
3
4
5
6
user#d23c81a3 id:int first_name:string last_name:string = User;

--- functions ---

// API functions
getUser#b0f732d5 int = User;

第一眼看上去,你可能会看得比较懵懂,起码我第一眼看过之后心里就开始小鹿乱蹦了。这片代码被--- functions ---分为上下两个部分,telegram规定了,在这之上的为类型定义区域,而这之下的为方法定义区域,另外,如果你非要在方法定义区域之下定义类型,telegram说了,你要用一个--- types ---进行分割。

首先,我们来看看类型定义:

user#d23c81a3 id:int first_name:string last_name:string = User;

这里被等号分成了左右两个部分,左边的为构造方法,右边的为具体类型,所以这里定义了一个User类型。左边的构造方法中,user#d23c81a3为方法名,后面的为方法参数,方法参数的定义方式,是不是和Objective-C很像啊?找到亲切感了没?方法名中的#d23c81a3为方法唯一标识符,用来全局标识这样一个方法。

为什么要有方法标识符呢?其实很简单啊,在一般的编程语言里,方法其实也就对应了内存里的某一个地址,想一想函数指针,这样会更贴切。那么,在跨网络通信的时候,我们要调用另外一台机器上的某个方法,传递这样一个唯一标识符,就像传递函数地址一样,快捷而方便的就可以被识别、调用。所以我们要确保这标识的唯一性,默认情况下,telegram是使用方法签名计算CRC32的值,并以此来作为方法的唯一标识。比如这个定义,便是计算了user id:int first_name:string last_name:string = User的CRC32。

看完了类型定义,我们再来看方法定义:

getUser#b0f732d5 int = User;

是的,这下一眼就看懂了,其实类型定义和方法定义是同样一回事,只是在含义上有所不同。类型定义里,左边为构造函数,右边为具体定义的类型;方法定义里,左边为方法签名,右边为方法返回值类型。其实我们可以把类型定义里右边的具体类型,看作是左边构造函数的返回值类型,这样就更能明白它们的一致性了。

以上便是TL-Language最基础的语法,能够定义出这样一种描述性语言,我觉得还是值得敬佩的。它将方法和类型巧妙的融合在了一起,简洁而不失描述的准确性,最难得的是它的可读性还非常强,在接下来的介绍中,我们会越发觉得它的强大!

词法和语法

看完了基础语法,我们有必要看一下它的词法和语法的描述,官方给出了一个完整的描述文档:

学过编译原理的应该能很容易看懂这样的文档,没有学过的,我这里简略的介绍以下吧!(看懂的就可以略过下面部分

什么是Token?

token是语言中的最小组成部分,我们可以把它称之为单词令牌符号等,结合官方文档的具体例子如下:

1
2
3
lc-letter ::= a | b || z
uc-letter ::= A | B || Z
digit ::= 0 | 1 || 9

其中lc-letteruc-letterdigit都是所谓的token。编译器或分析器在对文本进行词素(lexeme)分析的时候,会按照预先定义好的token规则,翻译成一串token,这个过程也叫Tokenize(单词化),而后续的语法分析中,便是以token作为输入。

所以,考虑这样一个字符串aaB0,那么按照上面的token定义,会被处理成怎样的token序列呢?如果按照数组来表示的话,它应该被处理成以下结果:

[lc-letter, lc-letter, uc-letter, digit]

想必聪明的你,应该理解了token的含义了吧!关于token定义的这个表达式,左边是token名,右边是token的匹配模式,有点像正则表达式,而实际上,正则也是非常适合用来描述词法的。

什么是Syntax?

了解了token,那么syntax便很容易理解了,这里的syntax也就是语法,所谓语法就是将token按照一定的规则进行排列。语法定义表达式和token表达式极其的类似,左侧为语法名,右侧为语法匹配模式。我们来看一个完整的TL程序语法定义:

1
2
3
4
5
TL-program ::= constr-declarations { --- functions --- fun-declarations | --- types --- constr-declarations }
constr-declarations ::= { declaration ; }
fun-declarations ::= { declaration ; }
declaration ::= combinator-decl | partial-app-decl | final-decl
...

花括号代表里面内容可选,|代表或,通过这个语法,可以看出,constr-declarationsfun-declarations是完全相同的格式。所以,也应和了我上面所说的,类型定义和方法定义是相同的说法。

多态

词法和语法大致看一下就可以了,遇到不明白的定义时回头再看便可。那么现在我们进入一些TL-Language高级特性,首先我们来说说它的多态性吧!什么是多态?面向对象如果没有白学的话,应该都会很清楚,一个实体会有多种形态便是多态,最常见的便是子类继承父类,那么父类在运行时便会有多态的特性。在TL-Language里,多态并不是体现在继承,我们看一看下面的定义:

1
2
responseOk data:string = DataResponse;
responseFailed code:int error:string = DataResponse;

这里便体现了多态,同样是DataResponse类型,却会有两种形式,不同的形式附带的成员也不相同。考虑下,客户端如何识别具体是哪一种类型呢?对!我们有方法唯一标识符啊,通过CRC32计算,不同的方法签名会有不同的标识符,所以客户端很容易能够区分是responseOk还是responseFailed。有没有感觉到这个TL-Language比你想象中要强大了?

再思考一下,这样的多态特性能给我们带来怎样的便利?首先我们不需要用一个状态码来标识数据状态了,另外也去除了很多冗余的数据传递(特定形式下所需要的字段会不相同),而且从表述上来说,会更加明确了。

可选类型

看完了多态,我们再来看看另外一个高级特性,可选类型。所谓可选类型,就是在使用的时候,可以传递,也可以不传递值。还是结合实例来看吧:

1
2
3
4
5
resultFalse {t:Type} = Maybe t;
resultTrue {t:Type} result:t = Maybe t;

pair {X:Type} {Y:Type} a:X b:Y = Pair X Y;
map {X:Type} {Y:Type} key:X value:Y = Map X Y;

可选类型的定义,是用一对花括号包起来,上面可选类型中的Type,是TL-Language里面预定的一个类型,是用来描述类型的类型,可以称之为元类型,就和Objective-C中的Class类似。考虑一下,这里引入了可选类型,会给我们客户端解析带来哪些麻烦?

的确,因为参数是可选的,所以客户端解析的时候很难确认到底调用怎样的模式,这里方法签名都一样,所以通过标识符也无法解决问题。那么该怎么办呢?telegram给出要求,所有可选类型只支持Type和自然数,并且必须出现在参数的第一位,另外可选类型必需出现在返回类型中,否则就是不合法的定义。

通过这样强硬的要求,客户端便可以通过实际传参进行推导了,比如下面的情况:

1
2
3
--- functions ---
reqData data:(Maybe string) = responseData;
reqData data:(Pair int int) = responseData;

是不是可以通过实际传入的类型进行推导了啊?是不是了解了为什么必须要在返回类型中包含可选类型了啊?自己先多考虑和推导一下,绝对没有忽悠你。

泛型

看到这样的可选类型,有没有想过,它和高级语言中什么特性非常相似?对,泛型!swift中就有泛型,WWDC 2015之后,Objective-C中也有了泛型。那么,我们的TL-Language(什么时候成为我们的了?)中当然也有泛型。TL-Language中的泛型,就是基于可选类型实现的,我们再简单看看下面的定义:

1
2
resultFalse {t:Type} = Maybe t;
resultTrue {t:Type} result:t = Maybe t;

因为Type是可选的,并且它又是用来描述类型的元类型,所以,非常巧妙的,就完成了类似泛型的特性:

1
2
maybeInt (resultFalse int) = MaybeInt;
maybeInt (resultTrue int) = MaybeInt;

TL-Language针对resultFalse int这样的形式,还提供了语法糖使其更像泛型:

1
2
maybeInt resultFalse<int> = MaybeInt;
maybeInt resultTrue<int> = MaybeInt;

这样的定义和上面的定义是相同的,但它更有泛型的味道了。

装箱类型和类型依赖

再进入一个更高级的话题吧,不得不说这个TL-Language还是非常强大的,大家应该也都该赞同这一点了吧?首先看看什么是装箱类型。在高级语言里,我们应该都听说过什么是装箱类型,在MSIL里还有专门的boxunbox操作符,而Objective-C中,NSNumber就是一个很好的装箱类型,因为我们要取到真正想要的值,需要做一次类似拆箱的动作。

我们看看TL-Language中所谓的装箱类型,考虑下面这样一个定义:

1
int_cons hd:int tl:IntList = IntList;

这里第二个参数引用了返回类型本身,那么这样形成了怎样一种形式呢?对,类型递归,所以通过这样的方式,我们可以定义一个列表,而在解析的时候,我们必须不断对第二个参数进行“拆箱”,这便是TL-Language中的装箱类型。

看完了装箱,我们再来稍微讨论下类型依赖吧!类型依赖,便是参数中的某个类型依赖与另外一个参数。结合下面的定义来解释吧,考虑定义一个多元组:

1
2
3
4
5
6
tnil = Tuple0;
tcons0 hd:int tl:Tuple0 = Tuple1;
tcons1 hd:int tl:Tuple1 = Tuple2;
tcons2 hd:int tl:Tuple2 = Tuple3;
...
tcons_n hd:int tl:Tuple_n = Tuple_(n+1)

可以看到,我们的第二个参数,依赖于元祖的具体个数,对于解决这样的问题,强大的TL-Language给出了下面的定义方式:

1
2
tnil = Tuple 0;
tcons {n:#} hd:int tl:%(Tuple n) = Tuple (S n);

第一个n是可选类型,是一个自然数,别问我啥叫自然数,%(Tuple n)便是引用了这样的自然数,百分号的作用,是在表达式中直接定义新类型,所以(Tuple n)是种新类型,右侧的Tuple (S n)中,引用的S是一个预定义表达式:S : # -> #,其中 S n = n + 1。这里有点难理解哦,考虑下,这样的定义是怎样的一种递归,比如Tuple 3会被转换成怎样的等同定义方式?

其它修饰符

最后,再补充一下TL-Language里的一些其它修饰符吧!万能的TL-Language还给我们提供了什么呢?

  1. !修饰符:代表“非”的意思,比如invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X;,这里query的类型即为所有非X的类型。
  2. $修饰符:代表所有符合类型的表达式都可以作为参数,类似于swift中的自动闭包。
  3. @修饰符:强制将所有可选类型变为必选,比如con {X:Type} = Opt X;,那么@con便是con X:Type = Opt X;

总结

好了,本还打算介绍下序列化相关的东西,但时间似乎并不够用了。所以,留给感兴趣的人自己去看文档吧!

通过本篇博文,相信大家会对TL-Language有了一个比较透彻的认识,作为一门描述性语言,还是做得相当到位了,也有很多值得我们去借鉴的。比起XML,这样的语法和灵活度是有过之而无不及的!

还是那句话,学习,成为更好的自己!加油吧,各位!