静态类型推导

前面说泛型的时候,提到了C++模板的实现方式是动态特性静态化,在实际情况中,这是一个提高效率的好办法。动态性的好处是灵活,开发简便,静态性的特性是效率高,编译期检查较好,因此很自然地就有一个问题,能不能各取所长,达到两全其美?应该说,在一定程度上是可以的,比如这篇即将讨论的静态类型推导,简称类型推导,因为动态类型无所谓什么推导。个人认为类型推导是编译原理最复杂的东西之一,其他复杂的有垃圾回收,代码优化等,后面会简单提到 

类型推导,是指一个静态类型语言,在代码中可以不显式声明变量的类型,由编译器根据代码分析来判断出正确的类型,例如: 

i = 1 
j = i * 2 
k = i + 1.234 
print i, j, k 

强调一点,这个代码和本篇下面的代码都是静态类型语言(伪代码形式,能看懂就行了),虽然写起来像是动态类型。对于上面的代码,编译器推导结果如下: 
int i = 1 
int j = i * 2 
double k = i + 1.234; 
print "%d %d %f" % (i, j, k)

这个过程在go和C++中已经有了,一个是:=赋值符号,一个是auto关键字,在定义变量时候不指定类型,由初值表达式的类型得出,在使用上增加了很多方便,不过,这个只是最初级的类型推导 
注:auto关键字在很早的C标准就有了,后面C++修改了它的语义,这是一个不太兼容的地方,不过原本C中auto语义基本用不到 

go和C++这种机制只是在赋初值时候使用,也就是说声明的位置还是确定的,而像上面的伪代码例子则不是,这有一点差别,就是在编译期做类型检查的时候查错机制不同,前者是先确定类型,后者则是检查使用的一致性,比如: 
i = 1 
i = "abc" 
i = 1.234 

编译器如果三次赋值是在三个文件中(假设i是extern变量这类),则编译器并不清楚程序员的真实意图是什么,因此只能报一个类型不一致的错误,当然这是假定了go和C++的这个语法的初值符合程序员意图,否则也可能报一个莫名其妙的错 

由于调用函数时,参数传递可看做是一个给函数的局部变量赋初值过程,所以C++的函数模板的调用,也是上面说的这种类型推导,所不同的是,由于函数只是一段处理代码,所以用不同类型的参数调用,会产生不同的函数实例,只要每个实例都能编译通过即可 

这是通过表达式的值来推导变量类型,顺便讲反过来推导的例子,通过变量类型也可以推导表达式中的某些类型,不过这个很少见,主要是在java的泛型中: 
static <T> Vector<T> f() 
{ 
    return new Vector<T>(); 
} 
Vector<String> v = f();
 
这个f函数的泛型类型T,没有办法通过传入参数来决定,因为就没有参数,同时java好像也不能用f<String>()这样的语法,所以只能通过被其返回值赋值的变量的类型来决定,由于前面讲过的java的泛型实现原理,可以改成这样: 
static Vector f() 
{ 
    return new Vector(); 
} 
Vector<String> v = f(); 

不过之前也说了,java的泛型是为了编译器做更多安全检查,所以这样写会有告警,如果写成这样则会报错,尽管不影响实现原理: 
static <Object> Vector<Object> f() 
{ 
    return new Vector<Object>(); 
} 
Vector<String> v = f(); 

这种推导主要原因大概是java不支持f<String>()这种方式,编译上做一个补充罢了。但它是属于后续补充推导的,也就是说,编译器在编译赋值语句的右值的时候,信息是不完全的,只能知道f大概是返回了一个Vector,而只有在看到左值的时候,才对f的返回值做一个补充。当然,编译器在编译赋值语句的时候,一般应该还是先编译左值,这只是从执行的顺序来看 

假如所有的变量都没有显式声明类型,根据初值来确定类型(go和C++这种),似乎也没什么问题,如果有多个赋值语句,搞不清哪个是所谓“初值”,至少也可以检查不一致性。这个结论对于基本类型还算没错,但一旦引入对象等就有问题了,例如下面的代码: 
i = null 
... 
i = new vector() 
... 
i.add(123) 

编译器看到第一句,只知道i是个对象,还不知道具体是什么,看到第二句,才知道i是个vector,但不知道元素是什么, 再看到第三句,才知道i是vector<int>。也就是说,表达式的值是模糊的,不一定有精确的类型能立即推导出变量的类型,可能需要在这个变量所有被赋值或被改变(比如上面的add方法调用)的地方做检查,收集各种信息然后汇总,拼凑出一个完整的轮廓,当然具体这个例子,如果在一个函数的上下文中,似乎还不难推导。一个细节问题是,如果信息缺失怎么办,比如代码里就一个i=null,其他啥都没有,这种情况下可以warning,或者随便给安一个Object类型,反正也没用到 

如果考虑到函数或类,这个问题就更复杂了,首先明确一点的是,一个函数或类应当是一个模板,而不是一个确定类型的,比如说定义一个f(i),则这并不是f(SomeType i)的简写,如果是的话,我们只要找到它的一个调用的地方,就能很容易根据参数确定了,但是实际情况中一个函数应该是能接受各种类型的处理流程,例如: 
func f(a, b): 
    return a + b 
f(1, 2) 
f("hello", "world") 

在编译阶段会将f视作一个模板,根据传入类型的不同将其转换成各种实例,如果和变量做同样处理,上面代码报f的类型不一致(int(int,int)和str(str,str)),则要求在每个确定的程序中,f确定,这是不现实的,最简单的,定义一个func sort(a),就只能一种类型来用,这反而还不如显式指定类型然后重载 

考虑如下代码: 
func f(a): 
    print a 
func g(a, b): 
    a.add(b) 
i = new vector(); 
f(i) 
g(i, "hello") 
f(i) 
j = new vector(); 
j.add("world") 
f(j) 
k = new set(); 
g(k, 123) 
f(k) 

这个代码中,当编译器看到f(i)的时候,i的类型是vector<?>,第一个f的类型是void(vector<?>),然后在看到g(i, "hello")的时候,分析g的代码发现b是字符串,而传入的i会被用来add(b),于是i就是vector<str>,然后返回去第一个f就是void(vector<str>),接下来看到第二个f(i),这个f的类型跟上面一样,于是合并,再下来两句可以推导出j也是vector<str>,第三个f和前面也一样,然后下一句k是set<?>,分析g(k, 123)的代码发现k是set<int>,最后一个f就是void(set<int>),于是最后的代码会成为: 
void f(vector<str> a): 
    print a.to_str() 
void f(set<int> a): 
    print a.to_str() 
void g(vector<str> a, str b): 
    a.add(b) 
void g(set<int> a, int b): 
    a.add(b) 
vector<str> i, j 
set<int> k 
...//下面的代码略 

上面这个例子用了重载来扩展函数,但是有时候还需要考虑返回值,比如: 
func f(): 
    return new vector() 
i = f() 
i.add(123) 
j = f() 
j.add("hello") 

于是经过推导,展开的时候,就得成f_ret_vector_int和f_ret_vector_str这种形式,由于返回值不在重载控制范围。这种情况就是上面那个java的例子的扩展版本,只不过java那个是必须赋值时候推导,这里需要后续根据add行为来推导,更复杂一些 

这里只举了函数的例子,类的情况也类似,考虑到类的方法也是模板,可以说更复杂,大家自己想象 

于是我们看到,如果自动推导跨了类和函数,会比较麻烦,不过也是能做到的,在一个程序编译的时候,我们强制规定main函数只有一个实例,这是合理的,然后从main开始,画出所有函数的调用树,对于未确定的类型,用一个待定号码来表示(例如上面的“?”),等确定了再回填,最后再合并类型相同的函数即可,问题在于,如果只是一棵树还好办,但如果加上递归调用,树就变成了图,例如: 
func f(a, b): 
    ... 
    f(new vector(), b + 1.0) 
    ... 
    f(new vector(), (int)b) 
    ... 
    a.add(b) 
f(new vector(), 1) 

这里编译到f的调用的时候,参数是vector<?>和int,然后可以推出传入的a是vector<int>,然后再第一个递归调用的时候,需要新建一个参数为vector<?>和double的f,第二个递归的时候可以复用之前的第一个f的实例,这个推导还是很麻烦的 

实际上,针对f的静态分析本身可以总结出一些特点,比如看到a.add(b)这句,虽然不知道传入的a和b具体类型,但是知道它们的约束,即a必须有一个add(typeof(b) b)的方法,用静态分析先总结约束,然后每次调用时候就能直接检查和推导实例,不用每次分析代码,但我觉得算法和数据结构表示可能太复杂了 

说到这里再提醒一句,vector这个类也是模板,new的时候是不知道它具体类型的,根据后续add来决定,因此上面说的“a必须有一个add(typeof(b) b)的方法”的约束应该是说f的类型是void(vector<T>,T)这样比较合适,这还没完,如果a.add(b)下面再加一个b.add(a),那这个约束该怎么写呢,泛型表示都是无限递归了,还有,假如这个add不是f里面,而是在更深的调用,甚至是回调函数,那写个编译器分析这个我只能呵呵呵了,有心无力啊,还是交给专家来研究吧


考虑到递归,有时候很简单一句话也不一定容易处理,比如: 

class A: 
    A(i): 
        this.i=i 
i = A(i) 

i是一个类A<A>的对象(实际表示形式是无限递归的,用一个单独的A表示自身),但是这个推导就可能需要把这句赋值分解成: 
i = A() 
i.i = i 

这样可能会好点,但如果是A的两个实例互相引用: 
class A: 
    A(i): 
        this.i = i 
        this.j = null 
i = A(123) 
j = A("hello") 
i.j = j 
j.j = i 

这种代码生成的类型系统就很复杂了 

虽然类型推导很复杂,但还是有很多语言实现了,或部分实现了,函数式编程比较多,据说Haskell就做的很好,但是命令式编程里面貌似比较少,或者就是只支持C++和go这种“半吊子”推导,究其原因,我觉得除了上面说的实现的复杂性以外,代码可读性是一个很重要的原因,毕竟一个变量的类型如果要读完代码(甚至要跨越几个库)才能知道,这是非常难以维护的,相对来说多敲几个声明成本是很低的,如果是大项目,打这点字死不了人,如果是小程序,那直接上动态类型语言一般也能接受了,因此,没有必要做很完整的类型推导,不过在理论方面,这仍然是编译原理中一直被深入研究的一个话题

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值