Java泛型的中庸之道

2023-10-24 08:59
文章标签 java 泛型 中庸之道

本文主要是介绍Java泛型的中庸之道,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

代码组织和复用是所有计算机编程的基本手段:编写一次,多次使用,并在一个位置保存代码。—— 《Java编程思想》

面向对象的程序设计语言都有一种共有的复用方式——依赖继承体系的多态,来实现一种纵向复用。除此以外,诸如C++和C#还拥有不依赖继承体系的泛型,来实现一种横向复用。而Java,这个在其1.5版本才加入泛型特性的语言,为了平滑兼容已有的非泛型代码,其泛型依旧充满了继承结构的味道,只能说是一个瞻前顾后、遵循中庸之道的无奈的伪泛型。

这里写图片描述

以下我们只讲Java的泛型。

一、泛型的创建和使用

1.1、泛型创建和使用之间的矛盾

对于Java泛型,编写泛型类和泛型方法是相当简单的,但是编写出能够操作其泛型类型的泛化代码就需要额外的努力了,这些努力需要类创建者和类消费者共同付出,他们必须理解适配器设计模式的概念和实现。——《Java编程思想》

如上所言,创建泛型很容易:

class Holder<T> {T t;void set(T t){//...}T get(){return t;}
}

如上Holder类就是一个泛型类,但是因为类型擦除的原因,在set()方法的实现中,你只能将变量t当作Object来使用,只能调用Object类的方法,因此类创建者的设计将会无比受限。

在创建泛型时,可以设置一个泛型上界,从而在一定程序上减轻泛型实现时的限制:

class Holder<T extends Fruit> {T t;void set(T t){//...}T get(){return t;}
}

这时,泛型擦除到Fruit类型,在set()的方法体中你便可以将变量t当作Fruit来使用。但是,这个类的使用者,就需要让自己的类都继承于Fruit类,才能塞到Holder中去,这对类使用者来说是一定程度上的限制,该泛型的泛化程度也因此被限制了。

这就像一个天平,天平的左端是“泛化程度”,天平的右端是“泛型实现”,根据类的实际情况,两者取其平衡,是在Java的泛型中必须权衡的问题。

1.2、泛型类/泛型接口的创建和使用

简单的泛型类创建上一小节已举过例子,还有几种稍微复杂的情形:

//在继承时创建泛型
class MyHolder<T> extends Holder<T>{
}//在继承时创建泛型,并对泛型参数做进一步扩充
class MyHolder<T,U> extends Holder<T>{
}//泛型类型参数除了必须是Fruit的子类外,还必须实现了eatable接口和tasty接口
class Holder<T extends Fruit & eatable & tasty>{
}

而泛型类的使用有如下两种情形:

  • 在new时,直接在<>中声明类型。 (若只在赋值语句的左侧的<>符号中有类型,说明用到了类型参数推断技术,该技术在1.7版本引入,在1.8版本进一步优化)
  • 在继承已声明类型的泛型类时,如下:
//重写父类方法时,可直接使用已参数化的方法形参
class MyHolder extends Holder<Fruit> {@Overridevoid set(Fruit t){//...}
}//直接在类中调用父类已参数化的方法
class MyHolder extends Holder<Fruit> {void callFatherMedthod(){//get()是父类Holder的方法,并且具有了Fruit类的参数信息Fruit t = this.get();}
}

泛型接口的创建和使用和泛型类类似,不再赘言。

1.3、泛型方法的创建和使用

如果使用泛型方法可以取代将整个类泛型化,那么就应该只使用泛型方法,因为它可以使事情更清楚明白。另外,对于一个static的方法而言,无法访问泛型类的类型参数,所以,如果static方法需要使用泛型能力,就必须使其成为泛型方法。——《Java编程思想》

//泛型方法创建
class GenericMedthodWrapper{static <T> void genericMedthod(List<T> list){//...}
}//泛型方法使用:不必指明参数类型,会自动进行类型参数推断,就好像该方法被无限次的重载过
List<User> users = new ArrayList<>();
GenericMedthodWrapper.genericMedthod(users)

二、类型擦除和擦除补偿

2.1、类型擦除

在C#中,List<int>和List<String>就是两个不同的类型,它们在运行期生成,有自己的虚方发表和类型数据,是真实泛型。

Java泛型说到底只不过是编译期的一个语法糖,所谓语法糖就是让你少写点代码也能达到相同的效果,而这你偷懒少写的代码,编译器帮你补上了,它是编译器实现的一些小把戏。所以Java泛型语法只对编译器生效,故而也就只在你自己的源码中存在,在编译后的字节码中,已不存在任何的泛型语义信息,它已经被编译器擦除了。

在泛型中的所有动作都发生在边界处——对传递进来的值进行额外的编译期检查,并插入对传递出去的值的转型。有且仅有边界是发生动作的地方。

在泛型代码内部,无法获得任何有关泛型参数类型的信息

2.2、擦除补偿

在泛型代码内部确实无法直接获取泛型参数类型信息,但是可以通过一些额外的补偿手段,间接地获取类型信息。

1、在泛型类中保存一个对应泛型参数类型的Class对象,通过Class对象的isInstance()和newInstance()方法。
2、在泛型类中保存一个对应泛型参数类型的对象,获取该对象后从该对象身上挖掘类型信息。
3、通过java.lang.reflect.ParameterizedType接口。

三、通配符

Java的泛型通配符,实际上是在泛型不变性的前提下,解决了泛型的协变和逆变问题。

关于协变逆变,以及涉及到的里氏替换原则,可参考下面两篇博文:
设计模式六大原则(2):里氏替换原则
Java中的逆变与协变

先厘清一点,泛型通配符是在使用泛型类的时候使用的,而不是在创建泛型时使用的。以下讨论的都是在使用泛型类时通配符的用法:

class Fruit {
}
class Apple extends Fruit {
}//对类来说,这样引用是没毛病的
Fruit fruit = new Apple();
//但这样引用就不行了,会报错
List<Fruit> fruitList = new ArrayList<Apple>()

3.1、<? extends Fruit> 协变

真正的问题是我们在谈论容器的类型,而不是容器持有的类型。但如果真的就想这样引用,该怎么做呢?如下这样就可以了:

List<? extends Fruit> fruitList = new ArrayList<Apple>();Fruit fruit = fruitList.get(i);
fruitList.add(new Apple()); //error
fruitList.add(new Object()); //error

但是,通配符引用的是明确的类型,但是这个 ? extends Fruit 意味着fruitList要持有某种具体的Fruit或其子类型,也就意味着编译器根本无法确定 ? extends Fruit 引用的是什么类型,所以编译器很决绝,直接不让你再通过fruitList.add()添加任何对象了,对,是任何,Object也不行。而通过fruitList.get()出来的对象即是Fruit类型。

3.2、<? super Fruit> 逆变

然而我就是像添加对象怎么办呢?可如下这样:

List<? super Fruit> fruitList = new ArrayList<Apple>();Object obj = fruitList.get(i);
fruitList.add(new Fruit());
fruitList.add(new Apple());  

? super Fruit 意味着fruitList要持有某种具体的Fruit或其父类型,所以此时就可以通过fruitList.add()添加Fruit类型或其子类了,但是通过fruitList.get()出来的只能是Object类型。

以上 ? extends Fruit 和 ? super Fruit 对于add()和get()的限制,让人初看有点匪夷所思,但仔细想想还是有道理的,需要稍加品悟。

3.3、<?>

还有最后一个更让人匪夷所思的通配符 —— 无界通配符<?>。使用无界通配符好像等价于使用原生类型,但其实二者还有有所区别的。还是那句话:“通配符引用的是明确的类型”,List实际上表示“持有任何Object类型的原生List”,而List<?>表示“具有某种特定类型的非原生List,只是我们不知道那种类型是什么”。

List<?> list = new ArrayList<>();
list.add(new Object()); //error

不能向这个list加入除了null以外的任何对象,这个道理和之前的 ? extends Fruit 是类似的,? extends Fruit 都不能加入任何对象,就更别说 ? 了。

无界通配符有一个重要应用:当你在处理多个泛型参数时,有时允许一个参数可以是任何类型,同时为其他参数确定某种特定的类型。比如声明一个 Map<String, ?>

四、泛型的真正内涵

先厘清几个概念(个人见解,理性看待),且只抓核心要害:

第一个范畴:

  1. 静态语言: 又可称“静态编译语言”,指从源码转为目标代码/中间代码的动作发生在程序编译阶段。
  2. 动态语言:又可称“动态编译语言”,指从源码转为目标代码/中间代码的动作发生在程序运行阶段。

第二个范畴:

  1. 静态类型语言:类型的区分标志仅在于你赋予这个类型的独特名字,而跟这个类型中含有的属性和方法签名无关。
  2. 动态类型语言:所有类型的名字都一样,继而也就不会再关心类型的名字,类型的区分标志仅在于类型中的属性和方法签名。

第三个范畴:

  1. 强类型语言:变量需指定类型信息,只有确是该类型的值才可赋给该变量。
  2. 弱类型语言:变量无需指定类型信息,任何类型的值都可赋给该变量。

不同范畴之间的概念,没有直接关联,比如动态类型语言不一定是动态语言,也不一定是弱类型语言。但是动态类型语言通过运行期再编译的方式确实更易实现且合理,所以大多数的动态类型语言都是动态语言,比如javascript、groovy,所以人们常把动态类型语言说成动态语言。但是,C++的泛型实现却是在编译期间,就通过动态类型语言特性完成的。

一门语言设计成动态语言还是静态语言,没有必然的因果逻辑关系,仅仅是语言规范中的人为规定,除去性能因素,动态语言和静态语言在最终的使用效果上可以说没有区别。但是一门语言是动态类型语言还是静态类型语言,对于程序员的日常编程的设计和实施都会影响甚大。

4.1、泛型的本质

所谓泛型,就是在静态类型语言中去渴求动态类型语言的特性。你也会发现真正的动态类型语言中,不会有,也没有必要有泛型这种东西,泛型只会出现在静态类型中。

泛型的目的就是:
(1)通过某种途径来放宽对我们的代码将要作用的类型所作的限制。
(2)同时不丢失静态类型检查的好处。

4.2、Java对缺乏动态类型的补偿

(1)Java中没有动态类型的特性,但是可以通过反射,达到和动态类型相似的效果。可以调用Class类的getMethod()方法和Method类的invoke方法。
(2)迎合基于继承的类型擦除,让我们的类来继承泛型上界,或者通过适配器设计模式来继承泛型上界。

4.3、Java的泛型究竟带来了什么

Java的泛型带来了一定程度的泛化效果,但更多地是带来了程序语言的可表达性,提升了语义准确性,以及将曾经的运行期检查提前到了编译期。

五、参考书籍

《Java编程思想》—— Bruce Eckel
第15章 泛型

《深入理解Java虚拟机》—— 周志明
第10章 10.3 Java语法糖的味道

《Groovy程序设计》—— Venkat Subramaniam
第3章 动态类型

这篇关于Java泛型的中庸之道的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/274117

相关文章

java使用protobuf-maven-plugin的插件编译proto文件详解

《java使用protobuf-maven-plugin的插件编译proto文件详解》:本文主要介绍java使用protobuf-maven-plugin的插件编译proto文件,具有很好的参考价... 目录protobuf文件作为数据传输和存储的协议主要介绍在Java使用maven编译proto文件的插件

Java中的数组与集合基本用法详解

《Java中的数组与集合基本用法详解》本文介绍了Java数组和集合框架的基础知识,数组部分涵盖了一维、二维及多维数组的声明、初始化、访问与遍历方法,以及Arrays类的常用操作,对Java数组与集合相... 目录一、Java数组基础1.1 数组结构概述1.2 一维数组1.2.1 声明与初始化1.2.2 访问

Javaee多线程之进程和线程之间的区别和联系(最新整理)

《Javaee多线程之进程和线程之间的区别和联系(最新整理)》进程是资源分配单位,线程是调度执行单位,共享资源更高效,创建线程五种方式:继承Thread、Runnable接口、匿名类、lambda,r... 目录进程和线程进程线程进程和线程的区别创建线程的五种写法继承Thread,重写run实现Runnab

Java 方法重载Overload常见误区及注意事项

《Java方法重载Overload常见误区及注意事项》Java方法重载允许同一类中同名方法通过参数类型、数量、顺序差异实现功能扩展,提升代码灵活性,核心条件为参数列表不同,不涉及返回类型、访问修饰符... 目录Java 方法重载(Overload)详解一、方法重载的核心条件二、构成方法重载的具体情况三、不构

Java通过驱动包(jar包)连接MySQL数据库的步骤总结及验证方式

《Java通过驱动包(jar包)连接MySQL数据库的步骤总结及验证方式》本文详细介绍如何使用Java通过JDBC连接MySQL数据库,包括下载驱动、配置Eclipse环境、检测数据库连接等关键步骤,... 目录一、下载驱动包二、放jar包三、检测数据库连接JavaJava 如何使用 JDBC 连接 mys

SpringBoot线程池配置使用示例详解

《SpringBoot线程池配置使用示例详解》SpringBoot集成@Async注解,支持线程池参数配置(核心数、队列容量、拒绝策略等)及生命周期管理,结合监控与任务装饰器,提升异步处理效率与系统... 目录一、核心特性二、添加依赖三、参数详解四、配置线程池五、应用实践代码说明拒绝策略(Rejected

一文详解SpringBoot中控制器的动态注册与卸载

《一文详解SpringBoot中控制器的动态注册与卸载》在项目开发中,通过动态注册和卸载控制器功能,可以根据业务场景和项目需要实现功能的动态增加、删除,提高系统的灵活性和可扩展性,下面我们就来看看Sp... 目录项目结构1. 创建 Spring Boot 启动类2. 创建一个测试控制器3. 创建动态控制器注

Java操作Word文档的全面指南

《Java操作Word文档的全面指南》在Java开发中,操作Word文档是常见的业务需求,广泛应用于合同生成、报表输出、通知发布、法律文书生成、病历模板填写等场景,本文将全面介绍Java操作Word文... 目录简介段落页头与页脚页码表格图片批注文本框目录图表简介Word编程最重要的类是org.apach

Spring Boot中WebSocket常用使用方法详解

《SpringBoot中WebSocket常用使用方法详解》本文从WebSocket的基础概念出发,详细介绍了SpringBoot集成WebSocket的步骤,并重点讲解了常用的使用方法,包括简单消... 目录一、WebSocket基础概念1.1 什么是WebSocket1.2 WebSocket与HTTP

SpringBoot+Docker+Graylog 如何让错误自动报警

《SpringBoot+Docker+Graylog如何让错误自动报警》SpringBoot默认使用SLF4J与Logback,支持多日志级别和配置方式,可输出到控制台、文件及远程服务器,集成ELK... 目录01 Spring Boot 默认日志框架解析02 Spring Boot 日志级别详解03 Sp