再议类型推导

在前面的理论部分,讲过静态类型推导,不过当时只是提到了概念,主要是为了证明这玩意是比较复杂的,之所以有必要再议一下,是因为这个对larva的效率还是挺重要的,事实上我只做了一个很简单的推导算法,就将larva在dhrystone上的速度提了一倍;另一方面也是之前讲的比较简略,再深入一下


前面讨论反向运算的时候提到,常见计算机的CPU只能直接处理整数和浮点计算(地址不在这次讨论范围),不考虑算法,一个程序在语言方面的提速,其实就是将抽象的计算过程编译为尽可能少的CPU指令,优化不能修改程序的原意,所以优化就等同于“少做多余的事”。比如就动态类型语言的设计而言,若直接实现,则一个很大的消耗是由于所有类型都是object的子类,基础类型也是


举个简单的例子,比如在larva中,计算a==b相当于a.op_eq(b),从语法上讲,这是一个运算,于是直接实现的代码可能是:
LarObj op_eq(LarObj b)
{
    boolean b = ...; //根据具体类型实现计算过程,结果在b
    return b ? TRUE : FALSE; //TRUE和FALSE是内置的两个bool对象
}
由于a和b运行时可能是任意类型,而且编译期也只能将其指定为LarObj,所以类型是LarObj,问题在于这个返回值,首先,这个函数无论如何只会返回TRUE和FALSE,因此返回类型可以指定为LarObjBool
第二点就很重要,考虑“==”运算,在绝大多数情况下,这个运算是用于if、while判断,或判断的子表达式中,而LarObjBool这个类型我规定它不能参与数学计算(python中是可以的),如果按上面的实现,则if a==b就转成:
if (a.op_eq(b).op_bool())
也就是先计算,对返回的对象取其boolean值,根据上面,op_eq只返回两个对象,因此可以优化成这样的实现:
boolean op_eq(LarObj b)
{
    return ...; //直接返回计算结果
}
if (a.op_eq(b)) ...
这样一来就达到了优化的目的,如果判断语句很多,提速很明显
当然会引入一个问题,如果出现result=a==b这种语句怎么办呢,编译器会将其处理为:
result = a.op_eq(b) ? TRUE : FALSE;
虽然在这种情况下,会多一个判断,但相比上述原始的实现,少了一个方法调用,速度还是快很多的,更重要的是,bool运算的结果极少会赋给一个变量或参与计算,绝大多数情况都是用在if和while中,因此这个特殊处理是非常有价值的,类似的处理方式也在其他内置操作有体现,比如cmp、contain、len、hash之类的运算上
P.S.len和hash返回类型是int且参与计算,如果不做其他处理,则类似len(l)/2会被编译为:
(new LarObjInt(l.op_len())).op_div(CONST_INT_2)
这种情况下,限定这俩操作的返回值是没有什么意义的,但是如果结合类型推导,就可以直接编译为:
l.op_len() / 2
这个后面再说了


先扯这一堆,一是说明这个实现上的细节,二是指出在动态类型语言中类型方面有很大的优化空间,之前在静态类型推导一文中提到,动态类型语言无所谓推导,其实说得不合适,动态类型语言也是可以根据代码分析来确定部分变量类型的,像上面这个例子,虽然原则上所有变量和引用都是object型,但对具体的场景确定类型来提高效率,在写代码的人看来感知不到,并不违反原则


先看一个前面静态类型推导中的例子:
a = 123
a = "hello"
这个代码在静态类型语言中一般是不允许的,如果有推导,则会出错,因为a的类型是确定的,却用两种类型赋值,假设编译器以第一个赋值为准(类似C++的auto或golang的:=),可能会报错“a的类型是int,不允许将string赋值给a”
然而稍微改下这个例子:
class Z
class A extends Z
class B extends A
class C extends A
b = new B()
b = new C()
编译器看到第一个赋值,认为b的类型是B,然后在第二个赋值处报错,而程序员本身的意思是b的类型是A,然后先后将A的两个子类实例赋值给它,如果语言规定这种情况下需要对b做类型声明,或在前面写一个b=new A(),就显得太笨拙了


因此,前面讲的静态类型推导不完善,推导过程不是说找到一个赋值确定类型,然后检查其余,或检查所有赋值处的一致性,而是综合所有赋值操作,找到一个最合适的类型指派,比如上面的例子,b的类型经过推导应该是A或Z都可以,它们都是B和C的基类,但是最佳指派应该是A,再上面的例子,由于整数和字符串没有公共基类,所以编译器应该报错


然后回过头看动态类型语言中,还是以上面a的例子,在动态类型语言中,所有类型都是object的子类,因此由于a被分别赋值为整数和字符串,它应该是object类型,不应该报错。这里就再次看到了动静态类型体系的关系:动态类型是静态类型在“所有类型都是object的子类”这一条件下的特例


这里引入一个兼容的概念,假设一个类型A的对象(或值)可以不加转换地赋值给另一个类型B的变量,则称B兼容A,例如,基类兼容派生类,长整数兼容整数,等等
P.S.这两句放一起,容易让人产生“long是int的基类”的看法,这个说法有点奇怪,但就兼容性角度来说,是对的,int可看做是long的特化


对有些代码,类型推导可能不能从最简单的常量赋值开始,比如写出如下代码:
a = b
b = a
当然,如果进行数据流分析,单独这两行代码是有问题的,因为没有赋初值,不过就语法上没有错误,从代码分析只能知道a和b类型相同或兼容,编译器可能需要计算出它的一个最优指派


再复杂一些,考虑容器,比如list,假设我们将list的元素类型也做类型推导,情况跟变量类似,然而这样会跟之前一样引入递归的问题,比如:
func f(l):
    ...
    l.add(123)
    f([])
    ...
f([])
像之前的文章中说的,f看做一个模板,看到最后f的调用,构造一个f函数,参数类型是list<?>,然后分析这个f函数实例的代码才知道是list<int>,但是同时又引入了一个新的f函数实例,这个实例有可能跟原来的不同,于是推导过程就陷入无限循环
前面文章将这种情况的原因归为后续代码分析推导,但事实上,即便是C++的模板这种确定性推导也有这个问题,比如:
template <typename T>
void f(T t)
{
    vector<T> v;
    f(v);
}
这个模板,无论以什么类型的参数调用,都会陷入无限泛型定义,最终编译器会耗尽内存而失败,vs还限制了编译器的heap size,不至于造成很大后果,gcc貌似会一直吃内存到堆耗尽
然而类似的代码在java中就没有编译问题,因为java的泛型不是用代码扩展实现的,所以模板本身可以正常编译


再回到上述动态类型的list类型推导问题,如果只是规避无限递归,可以规定list跟代码相关,也就是说代码中每个“[]”作为一个列表,有固定的元素类型,而代码不扩展,这样一来,只要从一个“[]”开始,追踪它所有可能对元素做改变的地方即可


收集所有赋值(包括改变容器元素内容、函数调用参数传递等)的地方及所有表达式,可以构造一张关系图,因此类型推导问题就变成:找到对所有需要确定类型的元素的一个类型指派,使得关系图成立


很容易看出在动态类型体系下,上述问题有一个很简单的解:只需都指派object,就一定成立,不过这么干没啥意义。然而不幸的是,这个问题是一个CSP(约束可满足性问题)


CSP是指这样一种问题,有若干需要求解的元素,输入这些元素之间的约束,找到一个满足这些约束的解,且一般都要求在某种评估条件下最优,类似解方程,比如SAT,线性规划等,丢番图方程也算一种CSP


CSP通常都是NP-hard的,因此输入约束和要求解的元素很多的时候,找到一个最优解是无法操作的,一般来说有几种方案:
1 具体到实际面临的CSP问题,可能不是NP-hard,这需要结合问题特点找一个多项式算法
2 采用近似算法,找到一个尽量最优的解
3 在可接受范围内增加一些约束和条件,将其转为一个相对容易解决的问题(有多项式算法,或规模降低)
对于类型推导,我查了一些资料,一般而言这是一个NP-hard问题,对于动态类型语言的类型推导,我也不清楚是否存在多项式算法,因此在做larva的时候,采用了上述第三条方案,通过增加一些限制条件,放弃了完全推导,简化为一个很容易解决的问题,并且也有相对较好的性能提升
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值