类型安全

类型安全

一. 什么是类型安全?

*Well typed* programs *cannot go wrong*

良好类型化的程序不会出错.

If a program has been written so that no possible execution can exhibit undefined behavior,

we say that program is well defined.

如果执行一个编写好的程序不可能表现出未定义的行为,我们称之为良好定义的程序.

If a language’s type system ensures that every program is well defined,

we say that language is type safe.

如果一个语言的类型系统可以保证每个程序都是良好定义的,我们说这个语言是类型安全的.

看一个C语言数组越界的示例:

int main(int argc, char **argv) {
   unsigned long a[1];
   a[3] = 0x7ffff7b36cebUL; //数组越界
   return 0;
}

越界访问一个数组,会怎么样?C语言的类型系统并为对这种行为进行定义,所以该行为是未定义的,C的类型系统并为保证程序行为是良好定义的,所以C语言不是类型安全的.

Java语言规范对数组越界有明确的定义,会抛出ArrayIndexOutOfBoundsException.那么Java是类型安全的吗?可能是.看一个例子:

List stringList = new ArrayList(10); //声明一个期望使用字符串填充的列表
stringList.add("string1");
stringList.add("string2");
stringList.add(1);  //goes wrong !!!  
stringList.add(false);
stringList.add(new LinkedList());

//这导致后续的处理无法确定
//如果我们能确保stringList中的对象全部是String.那么可以使用如下遍历
for(String aString : stringList){
    //do something with aString
}

//但是由于stringList中的对象类型不确定,java只允许如下方式遍历
for(Object anObject : stringList){
    String aString = (String)anObject;
    //do something with aString
    //但是无法保证上述的强制类型转换是一定成功的 !!!
}

这样声明的List在运行时可以填充任何类型的对象.语言并没有表达我们想要定义的内容,这也不能称之为良好定义的.我们期望类型系统帮我们实现这一约束,Java1.5以后可以类型系统提供了泛型来支持这一诉求,上面的代码声明可以改为:

List<String> stringList = new ArrayList<>(10);
stringList.add("string1");
stringList.add("string2");
// stringList.add(1); 编译时检查不允许该操作

//这样就可以使用如下的方式遍历
for(String aString : stringList){
    //由于编译时检查的存在,可以确保stringList中的对象全部为String类型.
    //do something with aString
}

Java可能是类型安全的,取决于我们如何使用它的类型系统.

二. 我们需要类型安全的目的是什么?

我们编写程序的过程可以描述为使用编程语言表达自然语义,并期望计算机可以理解并按预期的语义来执行,最终输出期望的结果.

我们使用提供静态类型系统的语言例如Java,是为了能够在编译期就发现问题,保证程序在运行期可以如我们所愿的方式执行.这需要我们尽可能利用类型系统去描述我们要表达的语义.

使用静态类型系统编程是利用类型系统表达自然语义的过程,我们期望使用类型系统保证我们的程序是良好定义的,从而保证运行期不会出错.

下面的图示表述了,程序/良好定义的程序/类型安全的程序之间的关系.

类型安全

我们通过代码对程序定义,代码逻辑越清晰那么程序定义越好,运行期月不容易出错.当我们利用静态类型系统写出类型安全的程序,就可以在编译期提前发现运行期可能出现的错误.

对应开篇提到的*Well typed* programs *cannot go wrong*.在此阐明观点

类型安全令程序在运行时执行预期的行为

三. 我们正确使用了类型系统吗?

要回答这个问题,可以先回答另一个问题,我们写的程序中利用的类型定义有准确/完整表达预期的语义吗?

举个例子: 定义一个创建指定类型的任务,任务类型只可以是(1:立即执行,2:延迟执行)

Task createTask(String title, int type) {
    return new Task(title,type);
}

上面的代码并没有约束type的取值范围,所以没有准确表达预期的语义,故该程序不是良好定义的.运行时将发生非预期的行为,例如将type设置为3.对该Task的后续处理是未定义的,可能产生无法预期的结果.改为如下形式:

Task createTask(String title, int type) {
     if(type != 1&& type != 2){
        throw new IllegalArgumentException("类型只能在(1:立即执行,2:延迟执行)中取值");
    }
    return new Task(title,type);
}

从语义上讲上述代码是良好定义的,但却没有有效利用静态类型系统.因为int type不能表达任务类型的取值范围.

改为如下代码,令其变为类型安全:

enum Type{
    IMMEDIATELY(1),DELAY(2);
    
    private int code;
    public Type(int code){
        this.code = code;
    }
}

Task createTask(String title, Type type) {
    return new Task(title,type);
}

具有如下好处:

  1. 对语义信息的表达更丰富

类型定义往往都是有名字的,而名字可以增加表达力.

  1. 可以执行编译时静态检查

上面的代码在编译期就能约束type的取值范围

  1. 令程序更加简单,容易理解

上面代码的调用者只要看到方法声明,就可以知道代码的输入输出是什么,无须看方法内部实现就能知道type的取值范围.

  1. 对IDE等开发工具更友好

由于静态类型系统提供的信息是符合语言的语法规范的,IDE可以对其进行解析然后进一步分析.帮助开发者在编码阶段发现错误.Intellij IDEA和Eclipse的代码编辑器均提供了这方面的支持,最常用的莫过于代码自动提示/完成功能.

  1. 对重构提供强大的支持

可以说没有静态类型系统的程序是非常难以重构的,由于静态类型系统能够提供程序的结构化信息,重构工具大都依赖于此.就算是不依赖工具的人工改代码的重构,也可以通过编译检查来验证自己的重构操作正确性.

由于int type不能准确限制type的取值范围,这种现象我称之为类型退化,类型退化会降低程序代码对人和机器的表达力.很明显会丧失上面的所有好处.

再看一个类型退化的例子1,相对复杂一点儿, 代码中定义接口表示记录应用更新的时间:

public class RequestJson {
    private String service;
    private JsonElement data;
    private String format = "json";
    
    public Integer getInteger(String name){
        return data.getAsJsonObject().get(name).getAsInteger();
    }
    
    //ignore constructor, getters and setters
}

public class ResponseJson {
    private T data;
    private int code;
    private String msg;
    
    //ignore constructor, getters and setters
}
// 接口方法定义 : 记录一个应用的更新时间
public ResponseJson logAppUpdateTime(RequestJson request) {
    // 获取参数
    Integer resourceType = request.getInteger("resourceType");
    Integer appId = request.getInteger("appId");
    Integer updateTime = request.getInteger("updateTime");
  
    // 参数校验
    checkRequired(resourceType, "resourceType");
    checkRequired(appId, "appId");
    checkRequired(updateTime, "updateTime");

    // do something with arguments .
    Object result = ...// handle arguments then output result;
    ResponseJson response = ResponseJson.newInstance(200,"成功");
    response.setData(result);
    return response;
}

首先分析接口定义表达的语义:

  1. 从方法名logAppUpdateTime上可以读到该方法是用来记录应用的更新时间的.

可以看出方法名称的重要性,不通过注释方法名称就可以提供的足够的信息告知调用者自己是做什么的.

  1. 然后看该方法的内部实现,定义了必要输入参数有三个resourceType,appId,updateTime.

这保证了程序在运行期不会发生非预期的行为比如记录了未知数据,如果方法实现中没有定义获取参数和参数校验的逻辑,那么输入只要是json格式都可以通过RequestJson中的data字段传递到方法中,无法知道data中到底存放了什么.

  1. 最后返回一个标准响应ResponseJson告知调用者记录是否成功了.

这依然是个良好定义却非类型安全的程序,具备如下缺陷:

  1. 语义表达不清

只看方法签名无法知道输入/输出的具体形式

  1. 错误的调用要推迟到运行期才能暴露
  2. 程序实现更复杂

在方法实现中需要明确的getInteger来获取参数,约束需要的入参的类型

  1. IDE几乎无法为方法调用者提供任何有用的帮助.

分析内部实现是非常复杂的,所以IDE通常无法为这种方法的调用参数构造提供辅助功能.

  1. 难以重构

假设要变更方法返回值result的实际类型或者result中的数据参数名,由于缺少类型信息,重构工具无法同时变更依赖result的代码

RequestJsonResponseJson不能准确限制输入和输出的取值范围表现出类型退化

要正确使用静态类型系统,需要尽可能避免类型退化.

四. 怎么处理类型退化问题?

  1. 明确自己要表达的语义,必要时进行分层拆解

有一句俗语

没有什么问题是分层解决不了的,如果不行就再分一层.

例如上面的代码,在语义上可以拆解为两层.

  • 按RequestJson和ResponseJson进行输入输出的接口调用
  • 具体的业务方法调用

用伪代码表示对接口的调用过程如下

// 第一层: 以RequestJson和ResponseJson为输入/输出协议调用执行的api方法
ResponseJson invokeApi(String api,RequestJson request){
    Method method = findMethodFromApiRegistry(api);
    //resolveArgValues使用方法参数的类型信息将request中的信息转换为业务需要的参数值
    //依然是良好定义的,当request中的数据不能准确转换为业务需要的参数类型时,运行期会如期抛出异常
    Object[] args = resolveArgValues(request,method.getParameterTypes());
    try{
        Object result = method.invoke(args);
        return ResponseJson.newSuccess(result);
    }catch(Exception e){
        return ResponseJson.newFailure(e);
    }
}

//第二层: 业务方法调用
boolean logAppUpdateTime(int appId,int resourceType,int appId,int updateTime){
    writeToDatabase(appId,resourceType,updateTime);
    return true;
}

这段代码与Spring MVC 中的Handler方法类似.将处理http协议的语义逻辑单独拆解出来,根据handler方法的参数类型和注解提供的源信息进行数据绑定.

  1. 尽可能利用类型系统表达语义

Java的静态类型系统提供了"原始类型,自定义类型,枚举,泛型"等等工具,请有效利用起来.

编程是一件需要持续学习的工作,通过不断重用经验,改进经验的过程,来熟练使用各种工具."类型系统"也是一种编程工具.阅读"Effective Java","Code Complete"和"Clean Code"这些书是个不错的开始.

上面的代码可以进一步类型化,增加一个应用更新时间的类型定义

//通过声明类型提供了了更多的元数据
//运行时可以告知writeToDatabase方法要保存到的表名是什么
//简化程序实现
@Table(tableName="app_update_log")
class AppUpdateTime{
    int appId;
    int resourceType;
    int updateTime;
    // constructor getters and setters
}

//业务方法变成如下形式
//由于AppUpdateTime的类型名称已经说明了方法记录的是什么
//所以方法名称可以缩短为log,整个方法签名却不会丧失表达力
boolean log(AppUpdateTime aut){
    writeToDatabase(aut);
    return true;
}

五. 什么情况下无法避免类型退化?

根据过往10年的Java变成经验,如下两种情况会可能无法避免类型退化.

  1. 与异构系统交互时

这种情况比较容易理解,http协议是个基于文本的交互协议,当Java方法与http请求进行交互时,需要做类型映射,由于http协议缺少强类型约束,所以java必须将所有的类型转换为文本才能于该协议进行交互.有或者java程序与关系数据库的交互,需要将对象映射为数据表.类似的转换和映射的过程有极大的几率损失类型信息导致类型退化.

看如下代码:

public class AppInfo{
    String name;
    long pv;
    long uv;
    String extInfo;
    
    // constructor ,gettters and setters
}

上面代码中的extInfo是为了将数据以json格式保存到数据库的文本字段中而声明为String类型的.损失了extInfo的实际类型信息.只看这个类型声明,完全不知道extInfo的具体结构可能是什么样子的,如果没有额外的注释也不可能知道这个String是json格式.

  1. 超出编程语言类型系统的表达能力

编程语言的设计或者说任何程序的设计都是需要考虑复杂度的,为了避免语言变复杂导致更高的学习成本和更更低的易用性,设计者往往会根据设计目标进行取舍,以保证类型系统的简单性.

看如下代码,假设我们要创建一个数组,数组的元素类型是List<String>:

// 我们期望的声明方法应该是这样的
List<String>[] stringListArray = new List<String>[10]; //compile error

// 但实际上Java只允许我们这样写
List<String>[] stringListArray = new List[10];

这是Java利用类型擦除来实现编译时泛型所带来的限制.有趣的是做出这一限制正是Java为了保证语言本身规范的类型安全2.所以并不是说用类型安全的语言写出的程序就是类型安全的.

有兴趣的读者可以对比一下Java和Scala.后者的类型系统号称"图灵完备",具备更强大的表达能力.

六. 总结

当我们利用静态类型系统编写类型安全的程序时,该程序会对人和工具都具有更好的表达力,保证语义得到相对充分的表达,进而令程序在运行时不会发生未定义的行为.

尽可能利用类型系统表达语义并对语义进行分层降低每一层语义复杂度,可以更容易用类型安全的方式实现语义.

当我遭遇了类型退化时,如果不可避免,那么最好使用分层方法将类型退化限制在最小范围内.


  1. 代码取自我参与的一个项目的遗留代码,做了大规模简化,只表现类型退化问题,原始代码具有更加复杂的结构,这样设计的原始目的是为了令项目中的所有接口遵循统一的输入/输出协议.带来的是类型退化和开发效率的下降.
  2. 对于不能创建含有泛型参数的类型数组的具体原因有兴趣的具体原因可以参考这篇文章和这个知乎讨论
上一篇:使用Bean Validation 2.0定义方法约束


下一篇:改善编程体验: IdeaVimExtension介绍