Week 5
类型推定 Type Inference
-
静态类型推定可以在编译时拒绝一个程序以避免其在运行时可能产生的错误,同时在编译时指定类型
-
动态类型推定则是基本不做这样的检查,相反是在运行时评估时才会发现错误,在运行时才会指定类型
-
隐式类型指定
- ML是静态类型的
- 但是,ML是隐式类型指定的——很少需要明确直接写出类型
-
类型推定:给每一个绑定或表达式一个类型以使类型检查成功
- 当且仅当没有能够成功的解决方案时,类型推定失败
-
原则上,类型推定和特性检查时是两个步骤,但是在实践中通常在一起实现
-
类型推定可繁(细致简洁的方法)可简(接受或拒绝全部可能性)
-
ML中的类型推定关键步骤:
- 按顺序确定绑定的类型
- 除了相互递归调用
- 不能使用后期的绑定(尚未在环境中),可以使用前期的绑定
- 对于每一个
val
绑定和fun
绑定:- 分析所有的必要的事实(限制)
- 如果不是所有的事实都为真的话,出现类型错误
- 再然后,使用类型变量
'a
等来指定没有限制(或者说没有足够的事实限制)的类型(多型) - 最后,实施值限制
- 按顺序确定绑定的类型
-
ML的类型推定的一个核心特性:能够将类型推定为一个类型变量(泛型的思想)
-
但是,类型推定和带有类型变量的多型函数是完全不一样的概念
-
注意,类型推定过程不对表达式的确切值继续宁评估,因此只能按照上述过程进行推定,无论具体代码中的分支情况
-
存在一个问题:ML的类型推定是不完备的
- 有可能出现错误类型的对应(对于类型检查,此时这个类型为“任意类型”,那么有可能两次赋不同类型值仍可能认为合理)
- 问题所在,多型函数和可异变的组合使用
-
解决方案:值限制,即对于一个变量绑定,其可以拥有一个多型类型,当且仅当其表达式为一个变量或者值(没有任何的计算,以及函数调用)
-
SML中,对应的位置会出现警告并生成
?.X1
类型(这个根本什么都操作不了) -
然而有一个副作用:在某些情况下,即使不使用可异变类型,还是会被值限制禁止(
'a list -> ('a * int) list
- 解决方法:封装到一个函数绑定里
相互递归 Mutual Recursion
- 允许两个函数互相调用对方
- 实现一个状态记可以解决这个问题
- 一个问题:ML中要求按顺序使用环境中的各种绑定
- 解决方案1:特殊的语言结构
- 解决方案2:使用高阶函数变通之
- 新的语言特性:相互调用块:使用
and
关键字fun f1 p1 = e1 and f2 p2 = e2 and f3 p3 = e3
- 上面的三个函数可以相互调用,同时被加入环境,并且以一个包的形式同时被评估
and
关键词同样也可以用于递归的引用类型datatype t1 = ... and t2 = ... and t3 = ...
- 相互递归的调用:有限状态机的实现,用于处理不定长输入
- 每一个状态都是一个函数
- 状态的转换就是对剩余输入内容调用另外一个函数
- (这个思路可以泛化到任何状态机的实现中)
- 变通方法:使用高阶函数
- 定理两个函数
- 第一个函数能够传入一个函数,并将这个函数作用到其他输入内容中
- 第二个函数不传入其他函数,只传入同样的其他输入内容,内部直接调用第一个函数,并将自己传入
fun earlier (f, x) = ... f y ...
fun later x = ... earlier(later, y) ...
- 这个方法还有一个好处:相互调用的内容不必相邻
用于命名空间管理的模块 Module for Namespace Management
- 对于大型程序,顶级序列并不能很好的组织程序
- 使用
structure
定义模块structure MyModule = struct bindings end
- 模块内的绑定序列和顶级绑定序列是一样的
- 在模块内部,像一般情况一样使用先前的绑定
- 在模块外部,可以使用
ModuleName.bindingName
的形式访问绑定 - 命名空间管理:
- 实现分层的命名机制,而无需担心被覆盖
- 允许不同的模块内实现名称复用
- 直接访问模块内的内容,而无需使用模块名:Open
open ModuleName
可以直接访问这个模块中的绑定,而不需要模块名- 事实上并不推荐,当open一个模块时,往往会造成一些额外的影响
- 可以使用局部
val
绑定val map = List.map
签名 Signatures
- 签名(Signature):模块的类型,即一个模块包含的绑定及其类型
signature SIGNAME = sig type_for_bindings end
- 这里可以包括变量,类型,数据类型以及异常
- 可以先定义一个签名,指明“一个结构里面应该有什么”,然后根据这个签名定义符合的结构
structure MyModule :> SIGNAME = struct bindings end
- 这个模块只有在匹配这个签名(对应绑定有对应类型)才能通过类型检查
- 签名真正的作用:隐藏绑定及其类型定义,通过隐藏实现达到构建准确、鲁棒、可复用的软件的目的
- 使用一个模块,我们只能使用其签名中出现的内容,未出现在签名中的内容则不能在模块外部使用
- 有些情况下,我们希望能够隐藏某一个数据类型的具体实现以避免使用错误的方式构建实现数据,即在签名中声明
type MyType
并在对应的模块中进行实现
- 两个有效的使用签名隐藏的方法:
- 否认绑定存在
- 让数据类型变得抽象
- 对于一个数据类型,如果某一部分可以被暴露,那么我们可以函数的形式将其暴露出来(因为本身构造器就是函数)
- 签名匹配:假设有
structure Foo :> BAR
- 每一个
BAR
中的非抽象类型都要被Foo
提供 - 每一个
BAR
中的抽象类型在Foo
中应当以某种方式被提供(数据类型或者类型别名) - 每一个
BAR
中的类型绑定都应在Foo
中被提供,可以更加泛化(真的正常类型),或者更不抽象(针对抽象的数据类型) - 每一个
BAR
中的异常需要在Foo
中定义 Foo
可以包含比BAR
更多的绑定
- 每一个
等价结构 Equivalent Structures
- 抽象的一个关键目的就是允许等价(对客户端没有任何区别)的不同实现
- 对于一个具有抽象类型的签名,如果两个模块均实现这个签名,但是不同地实现这个抽象类型,那么二者也是不等价的
- 拥有相同签名的模块仍然定义的是不同的类型
等价函数 Equivalent Functions
-
一个比较基础的软件工程概念
-
使用这些方法更容易使两段代码“等价”:
- 抽象
- 更少的副作用(打印,异变)
-
判断两个函数等价:在不检查所有调用的情况下,确保两个函数对于所有调用等价
-
两个函数等价的条件,无论用在何处,两个函数有着相同的“可观察行为”,即在给定等价的参数下
- 产生等价的结果
- 有相同的(非)终止行为
- 以相同的方式更改内存
- 有相同的输入和输出
- 产生相同的异常
-
等价判定规则:
-
语法糖,完全等价
fun f x = x andalso g x
fun f x = if x then g x else false
-
一致的变量重命名和使用,但是注意不要命名为其他已经用于引用其他内容的名字
val y = 14; fun f x = x + y + x
val y = 14; fun f z = z + y + z
-
使用一个助手函数或者不使用是等价的(外界看不出区别),但需要同时留意当前函数和助手函数的环境
val y = 14; fun g z = (z + y + z) + z
val y = 14; fun f x = x + y + x; fun g z = (f z) + z
-
不必要的函数封装,但是如果有“计算”函数的情况,且其中有副作用,需要额外注意(比如打印)
fun f x = x + x; fun g y = f y
fun f x = x + x; val g = f
-
在忽略类型的情况下(let表达式允许多型),let绑定可以视为匿名函数的语法糖(等价!),但是类型系统会有区别(let表达式会允许多型而匿名函数不行,但是在同时满足两种的类型时,这两个形式的函数是等价的)
let val x = e1 in e2 end
(fn x => e2) e1
-
-
如果只在代码层面上考虑等价,就会忽略等价代码的性能问题
-
三种不同的“等价”之定义
- 编程语言级等价:给定相同输入,获得相同输出以及效果(完全不考虑性能)
- 渐近等价(Asymptotic Equivalence):(算法相关)性能与输入呈现相关性(忽略小输入,直接考虑逐渐增大的输入)(常数倍性能区别认为是一样的)
- 系统级等价:考虑常数消耗,性能调和(只考虑实际情况,会限制对极端情况的考虑)