larva的类型推导

上一篇说了,类型推导一般来说是一个CSP问题,所以larva采用简化问题的办法,用简单的算法来获得相对更高的收益,采用的办法主要是简化问题,麻烦的事情一律不做


首先考虑到的一点是,即便我已经有了算法来做完全的类型推导,也不一定面向程序来做优化,一个larva程序由若干模块组成,如果对整个程序来做优化,可能一个很小的改动就产生不同的结果,比如一个模块有一个函数f:
func f(n):
    print n
假设另一个模块调用f(123)且程序里只有这个调用,则n被推导为int,但如果后续扩展代码,又调用了f("hello"),则n就变成一个object,也就是说,一个模块的编译结果会随着调用者的变化来变化,虽然对于普通程序来说,这似乎不是一个问题,但显然不可能所有接口都用larva本身来实现,例如time模块就需要用扩展模块的方式来做,即使用lar_ext文件来声明接口,然后由java实现(具体扩展模块参考git的文档),编译器不可能在编译过程还去修改java文件,因此,在不修改larva语法原则(动态类型,不显式指定类型)的前提下,规定扩展模块的接口必须是全object类型,即函数的输入输出、全局变量都是object


另一方面,考虑模块接口的“回调”情况,假设我们一个程序有a和b两个模块,a是用larva写的,b是个扩展模块,但是b会调用a的接口,基于同样的原因,a中对应接口做成全object类型比较统一,也方便开发
P.S.绝大多数情况来说,扩展模块都是比较底层的被调用的库,但有时候也会出现这种回调情况,比如,a和b原本都是larva模块,后面为了效率或某些原因,将b改为扩展模块


于是针对这个问题,larva引入接口导出的概念,如果一个模块的类、全局变量、函数等需要被外部访问,则用export关键字说明,比如main函数需要说明为:
export func main():
不带export的为模块内部私有,继而就可以规定:一个模块导出的接口是全object类型,这样一来,类型推导算法就被限制在了模块内部,每个模块可以独立优化


第二个问题是对象的属性和方法,larva的设计原则是“动态类型,静态行为”,其实这个静态行为也只是针对函数、全局变量、局部变量这些编译期能感知的元素,既然有动态类型,则对象的属性和方法就难以感知(除非对对象本身先做类型推导,但这很麻烦),比如一个函数:
func f(a):
    a.k = 123
我们只能知道这个a对象所有可能的类型都有k属性,且在这里被赋值为int,但是如果不知道a本身的类型,就难以推导了


一个曾经设想过的折中的办法是,把所有同名属性看做是一个,比方说上面这个例子我们无法知道这个k在运行时会是哪个class的,但是,如果一个代码中所有对k属性赋值的地方都是int,那么就可以推导出所有class的k属性(如果有的话)都是int类型,这个设计的一个依据是,绝大多数同名属性的类型也是相同的,因为一个设计不太烂的程序,在命名方便也不应该随意,比方说有一个属性叫name,那无论是哪个class的,大多数情况下都是存代表名字的字符串,因此这个设计可以涵盖大多数情况,尽可能优化
然而,这个方案最后还是没有做,因为如上所说,larva的推导是模块内的,但是一个class没有export只是其他模块不能直接用它而已,不代表不能使用它的实例对象,比如:
class A:
    ...
export func new_obj():
    return A()
虽然外部模块不能直接调用A(),但是可以通过间接的办法,这种设计是为了隐藏A的一些实现,很常见的做法。这样一来,如果我们要对属性做推导,不可避免要进行整个程序的分析,跟上面的规定矛盾了
当然,可以在代码分析上做得更好,比如这个class A是私有的,编译器在模块内追踪它的所有使用地,看是否有传出给其他模块,若没有,则可以进行推导,不过这个分析就涉及一些麻烦的情况,比如A的实例被放入某个列表,而列表是从外部传来的等待,所以我没有做


于是,最终larva规定,class中的属性、方法都是全object类型,除了部分内置方法的特殊情况外(如上一篇说的op_eq的重载实现),这样类中的类型推导就仅被限定在方法的局部变量上。同时,基于同样的理由,规定容器元素(tuple,list,dict等)都是object,这样也去掉了容器元素类型的推导


有人可能问,是否可以像模块的export接口一样,设置类中属性和方法的对外权限,即类似很多语言的public和private,这样一来可以在类内部有更大的优化空间。关于这个问题,在理论上是可以做到的,但是,面向对象编程中的这种封装性是针对代码的,而不是针对对象,比如java代码:
class A
{
    private int n;
    public int add(A a)
    {
        return n + a.n;
    }
}
这里对于n的访问,是看它是否在类A的代码里面,如果是就能访问,即便是其他A实例,比如上面add的参数,但是在动态类型语言中若有这个代码,由于编译器不清楚a的具体类型,所以只能运行时检查,若a是A类型,则n可访问,否则报错,这就非常麻烦了


作为动态类型语言,python对这个问题的办法是,私有属性/方法名以双下划线开头,编译器修改这个名字,给它带上类名,比如上面的代码,虽然编译器不知道a的具体类型,但n这个属性会被改名为带类A的名字,因此如果传入的参数a不是类型A的实例,就无法访问,这其实是一种不完善的做法,它存在好几个问题:
1 编译器只是不分青红皂白简单对名字做替换,如果你有一个双下划线开头的全局变量,且在类中访问,就会失败,这可能是作者懒的做
2 原因同1,如果出现错误,报错信息会让人迷惑;另外,如果A中访问B的实例,但B中恰好有一个和A中私有属性改名后同名的属性(尽管绝大多数情况不会这么巧,但理论上的确存在),执行和预期就不符
3 和2情况类似,python在改名时只是简单加上类名,而不考虑具体是哪个类,如果两个模块都有一个名为A的类,则它们的代码可以访问对方的私有属性和方法,相当于同名类都是friend
总的来说,我觉得python这个设计功不抵过,所以larva中,类的属性和方法不做权限控制,都是公有


有了这些规定,larva的类型推导就集中在全局、局部变量(含函数参数)以及函数返回了,不过我还是懒得去做所有类型的推导,只针对int进行了优化,原因也如前所说,能直接在cpu计算的,也就整数和浮点数,而大多数程序,浮点数相对整数使用频率是很低的,所以仅仅针对int优化就可以得到一个比较高的性价比


由于只有object和int两种类型,而且object兼容int,具体算法就比较简单了,找到所有给待定元素赋值的地方(包括赋值、增量赋值、函数参数传递、函数返回等),构造一张映射关系表(待定元素映射所有给它赋值的表达式)。然后有些元素,比如export的全局变量,已经根据上面的规则标记为object了,而非int的常量当然也是object类型,于是对于每个表达式,可以去推断它是否是object类型,若一个表达式类型为object且赋值给某个元素a,则a就不可能是int了,用几个例子说明:
func test(list):
    a = 123 //此时a还是待定状态,因为可能下面有其他赋值
    b = 4.56 //b确定是object
    c = b + a //由于b是object,即便a待定,c都只能是object
    d = int(b * a) //由于最后会做int类型转换,这种情况下b*a就不用分析了
                   //因为无论如何最后都是int,但d仍然待定,原因同a
    e = list[0] //最后的运算是取下标,无论list是什么类型,e必定是object
    f = a + d //由于a和d都待定,所以待定
    a += 7.89 //a是object
    g = 0 //g待定
    h = i //h待定
    i = h ^ 12345 //i待定
    //test默认返回nil,所以返回类型是object

对这个例子的第一轮object标记的步骤如注释描述,它还需要第二轮标记,因为可能有些改变,例如第二轮中扫描到f的时候就会发现,a已经是一个object类型了,因此,f被推导为object,然后进行第三轮推导,发现没有改变,于是标记结束,这很像bellman-ford算法的反复松弛过程


需要说明的是e的赋值中,只要看到e是从下标运算得来,就必定是object,因为下标运算只能返回object,但是如果我们其他地方的代码调用了test(123),虽然不会改变e的推导结果,但list[0]是一个明显的错误,对于这种情况,有几种处理办法:
1 编译期做检查,确保int型表达式只进行和int匹配的运算
2 从行为反推,比如我们发现list有下标运算,立即确定list是一个object,即信任程序员,通过test函数局部就能提早推断,而处理其他地方的代码的时候,test(123)的编译结果会先将123打包成object类型
第一种方案显然是最友好的,不过我懒得做;第二种看上去也有些道理,但是对于这种代码:(a+b)[0],也无法推断a和b具体是什么类型(只能知道它俩不能同时为int)
larva现在的实现两种方式都没采用,而是在编译输出的时候,如果一个下标运算左边是int,就实时打包成object,当然这样一来运行时肯定会出错,就当是我偷懒了


完成object标记过程后,剩余的待定元素就都是int类型了,比如上面的例子,最后是int类型的是d,g,h,i,其中h和i属于互相依赖,语义上是错误的,但并不影响类型推导的正确性


这个算法还可以扩展,事实上,只要所有类型形成一个兼容链,就可以使用,比如,针对object、long、int三种类型做推导,它们是依次兼容后面的,所以先标记object,再标记long,最后剩下的就是int了
再进一步,如果类型形成的兼容关系不是一条单链,而是一棵树,比如object兼容long、float和str,long兼容int,这样也是可以推广上面的算法的,办法就是自顶向下做标记,但是需要一些回溯,比如:
a = 1
a = 1.2
b = 1L
b = "hello"
c = 2L
c = 123
d = {}
e = 456
第一轮:标记d为object
第二轮:标记第二层的long、float和str类型,于是a是float,然后发现,b既可能是long又可能是str,于是b被标记为object,这时候由于object标记增加,需要回溯到第一轮继续标记object
第二轮第二趟:标记a为float,c为long
第三轮:发现a还可能是int,与float不兼容,于是a为object,回溯到第一轮继续标记object
接下来重新搞第二轮,没有发现,于是类型系统中只剩下int,最后的e就是int了


简单说,这个算法就是一层层标记,对于一个变量出现不兼容赋值时回溯到object重新开始,最后完成标记,不过larva目前还没有做到这个程度,对常用的几个类型做推导,还是能够提升一些性能的,不过除了float以外,由于long和str本身也是对象,通过LarObj来操作只是减少了一些内联的几率,效果没有int那么明显,所以性价比就不是很高了


于是,有了int的类型推导,对于普通的整数运算,效率就能得到一个质的飞越,但是很多时候,我们需要的并不是简单几个变量的大量运算,经常涉及到数据在容器的存取,而容器中都是object,因此对于很多程序,这个算法还是不够的,简单的这个问题可以用上述变量d这种方式解决,从容器中取出的时候强转,然后计算,然后再打包放入容器,类似java的自动拆装箱,只不过手动实现,但这样一来不但有额外的打解包操作,而且依然没有解决大量int对象带来的问题,如GC等。其实larva采用了更好的办法来解决这个问题,当容器中的元素是int时,性能超过java的泛型容器,具体等后面再说了
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值