数据结构准备:包装类+泛型
提示:欢迎评论区讨论!
数据结构准备:包装类+泛型
- 1. 包装类
- 1.1 为什么要学包装类?
- 1.2 装箱和拆箱
- 1.2.1 注意事项
- 2. 泛型
- 2.1 写法
- 2.2 实现机制---类型擦除
- 2.3 什么是原始类型?
1. 包装类
*包装类(Wrapper Classes)*是Java为8种基本数据类型提供的引用类型,各自对应关系为:
基本数据类型 | 包装类 |
---|---|
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
char | Character |
boolean | Boolean |
记忆技巧:
可以清晰看到,只有int和char的包装类与基本类型相比变化较大,其余的包装类均为各自基本类型的首字母大写.
1.1 为什么要学包装类?
-
理解Java一切皆对象理念,包装类把基本类型转化为对象,保证了统一的对象模型.
-
让基础类型能参与面向对象编程,基本类型如int,double等本身没有属性和方法,不能调用成员函数,而包装类(如Integer,Double)提供了丰富的方法:
// 错误!ArrayList不能存储基本类型int// ArrayList<int> list = new ArrayList<>(); // 正确!必须使用包装类IntegerArrayList<Integer> list = new ArrayList<>();list.add(10); // 10会自动装箱为Integer对象
- 提供了许多有用的静态方法和常量
-
包装类让基本类型也能用泛型,泛型不支持基本类型,有了包装类,就能把基本类型装箱成对象,用在泛型中(后续讲解),学习包装类为后面学习集合,泛型打下基础.
-
为理解自动装箱与拆箱机制打下基础
1.2 装箱和拆箱
上小节在列举包装类意义时提到"学习包装类有助于理解自动装箱和拆箱机制",现在来讲解一下何为装箱拆箱.
- 装箱(Boxing):将基本类型转->(转换为)包装类对象
- 拆箱(Unboxing):将包装类对象->(转换为)对应的基本数据类型
在Java5之前,这个过程必须由程序员手动完成,即手动拆箱和手动装箱:
可以看到,代码较为繁琐,若是操作集合,会充满各种.valueOf()和.xxxValue()调用
Java5之后,编译器在编译阶段为我们插入了必要的代码,让我们可以直接赋值,就像类型自动转换一样:
编译器会自动补全Integer.valueOf()和.intValue()方法,使代码看起来简洁了很多~
但是要知道,自动和手动在运行时性能并无差异:
自动拆箱/装箱只是语法糖,即这种语法对功能没有影响,但是更方便程序员使用,让代码更简洁,易读.而最终生成的字节码指令完全一致,运行时性能完全一样
1.2.1 注意事项
装箱拆箱很方便,但是也隐含了一些潜在问题,若不理解其本质的话,容易犯错:
当我们运行上述代码时结果是一个true一个false,值为100的那个是true,200的那个是false,我们知道"=="用于引用类型时,比的是对象的地址(是否是同一个对象)而不单单是数值,两次比较的对象数值都相等,也就是说,a和b是同一个对象,c和d则是不同的对象.
这是为什么呢?
只有源码能告诉我们答案了,我们刚才已经分析,自动和手动装箱的底层都是valueOf方法,因此答案可能就出在valueOf上.每个包装类都实现了该方法,我们现在研究的是Integer的valueOf方法,于是我们可以按照下列步骤找到该方法:
双击shift键,在搜索框中输入Integer,并勾选右上方"include non-project items"
单击打开匹配选项,此时就打开了包装类Integer的源码
然后,点击idea左侧菜单栏的structure图标,在弹出窗口中找到valueOf(int)方法即可
现在我们来仔细看一下Integer.valueOf(int)方法的逻辑:
不用在意变量名,看结构就可以看到逻辑大概是:valueOf方法会对传入的int数值i进行一个比较,当i位于[IntegerCache.low,IntegerCache.high]区间时将对i进行一些处理后,然后从一个名为IntegerCache.cache的数组中查找并返回一个什么东西;而若i不在这个区间,将new一个新对象
结合一开始给出的运行结果,可以判断出,值为100时走的是上面的return;值为200时则是下面的return.(执行下面的return时,每次都会新new一个对象,地址自然不一样)
那么第一个return到底是什么逻辑?点开IntegerCache的源码,
可以看到,该类中定义了两个常量,low为-128,high为127,200不在[-128,127]这个区间内,因此每次都会新创建一个对象,导致c和d即使值相同,但是两个完全不同的对象.也验证了我们的想法.
此外,我们还可以看到该类下有一个名为cache,类型为的Integer[]的数组,在valueOf方法的主体中,有该数组的处理逻辑:IntegerCache.cache[i+(-IntegerCache.low)],这是一个巧妙的数组索引运算:
- 数组下标从0开始,此时满足0=i+(-(-128)),算出i=-128,这就是下标为0时对应数组元素中存储的值
- 根据区间[-128,127],可知数组的长度为256,数组下标为0->255,最后一个下标255对应的i值为255-128=127
可知数组cache中存储的值为-128->127,正正好就是对输入的i进行比较的那个区间
当i为100时,可知其对应的数组下标为100+128=228,在下标区间[0,255]内,于是程序就知道了:cache数组中有这个Integer对象,直接返回就好,不需要再创建了,这也就是当a,b都取100时,程序判定两者是同一个对象的原因.
这样做的用意----性能优化-享元模式
IntegerCache是Integer类的一个私有内部类,核心工作就是预先创建好缓存范围内的所有Integer对象,通过缓存和重用对象,避免了大量重复对象的创建和销毁,极大地节省了内存和减少了垃圾回收(GC)的压力。
经验总结:
在缓存范围内[-128,127]使用自动装箱,总是会返回同一个对象的引用,此时"=="是可以达到预期结果的,但这只是一个优化实现的副作用,并非设计初衷,在超出缓存范围时就会出现意外.因此,比较值时用.equals更稳妥
当然,缓存上限是可以更改的,可以自行查询,这里不做说明
2. 泛型
2.1 写法
泛型=“参数化类型”
想象一下函数的形参实参:定义一个函数时,参数x和y是形参,当你调用函数add(2,3)时,你传递了具体的值,也就是实参.
泛型将这个思想应用到了类型上,允许类,接口,方法在定义时使用"类型参数",在使用时在指定具体的类型.
上例子!
需求:实现一个类,类中包含一个数组成员,使得数组中可以存放任何类型的数据,也可以根据成员方法返回数组中某个下标的值.
在知道泛型之前,为了能让使数组中可以存放任意类型的数据,我们可以创建一个Object[]类型的数组,原因很简单,Object是所有类的子类,Object[]类型的数组可以存放任何类型的对象,包括包装类的,再结合上面我们讲的自动装箱,可以写出以下代码:
可以看到,该数组可以存储任何类型的对象,但是在从数组中取出元素的时候,必须手动进行强制类型转换,因为编译器不知道取出的对象具体实时什么类型;先不说对大量数据一个个进行强制类型转换是否现实的问题,一不留神就出错了:
一不小心转换错类型了,编译器是不会提醒的,但是运行时会抛出ClassCastException异常
而泛型正是为了克服Object方式的各种缺点而设计的,它能让你写出更加通用,安全,可复用的代码,避免强制类型转换和运行时的ClassCastException,那它是怎么写的呢?
写法:本例实现的是一个很基础的泛型类,写法的话,第一步先给类添加类型参数,然后将后面涉及到该值的类型由基本类型换成T就好.
泛型方法的写法演示:
public class Utils {// 泛型方法,<T> 声明在返回值前public static <T> void printArray(T[] array) {for (T item : array) {System.out.print(item + " ");}System.out.println();}
}// 使用
Integer[] nums = {1, 2, 3};
String[] strs = {"A", "B", "C"};Utils.printArray(nums); // 1 2 3
Utils.printArray(strs); // A B C
泛型接口:
public interface Generator<T> {T next();
}// 实现时指定具体类型
public class StringGenerator implements Generator<String> {private String[] data = {"A", "B", "C"};private int index = 0;@Overridepublic String next() {return data[index++ % data.length];}
}
需要注意泛型不能直接实例化!
你不能直接使用new T() 这样的写法,具体原因是Java的类型擦除机制(下一小节会讲),擦除之后JVM不知道T具体是什么类型,所以无法创建示例
对此,你可以先实例化一个其他类型的,后面进行强转
总结一下泛型的优势:
- 类型安全:在编译期捕获类型错误,避免运行时崩溃
- 消除强制转换:是代码更简洁,更清晰
- 明确意图:从类的定义中能清晰看出要存储什么类型的数据
2.2 实现机制—类型擦除
类型擦除指的是,Java的泛型只存在于编译期,在编译后的字节码文件中,所有于泛型相关的类型信息都会被擦除,替换为它们的原始类型,并在相应的地方插入强制类型转换
这是将字节码反汇编成人可读命令的截图,很多看不懂没关系,最起码能看到,出现了好几处Object,但是那个类型参数T却是一个没看到.
原因在于:编译器在编译期间会执行严格的类型检查,确保类型安全,一旦编译通过,生成的字节码不再包含泛型信息,对于JVM来说,myArr和myArr都被擦成了Integer和String的原始类型Object,类型完全相同.
这样看起来,怎么和自动装箱那么像呢,他俩对实际性能都没太大优化,毕竟只是作用于编译前,都是一些表面工作,真正运行的时候就看不到了.
泛型和自动装箱确实都是Java引入的语法糖,但它们对性能的影响和实际价值需要有更准确的认识:
- 泛型
- 性能影响:无额外运行时开销(因为类型信息在运行时被擦除)
- 主要价值:
- 类型安全:编译期检查类型错误(避免ClassCastException)
- 代码可读性:明确集合中元素的类型
- 减少样板代码:无需手动强制转换
- 自动装箱/拆箱
- 性能影响:有运行时开销,每次装箱和拆箱都会创建对象或调用方法,在循环中大量使用可能导致性能问题(频繁创建对象和内存开销).
- 主要价值:
- 基本类型和包装类型之间的自动转换
- 提升代码可读性
2.3 什么是原始类型?
什么是原始类型?
原始类型是指擦除了泛型信息后的泛型类或接口.
当一个泛型类被使用但不提供参数时呈现出的状态就是原始类型,也就是字节码中被擦除类型之后的类型.
还是用MyArr这个例子,
直白地说,原生类型的意思就是:“我知道这个类是泛型,但是我选择忽略它的类型检查”,此时的MyArr就是MyArr的原始类型,使用起来和不使用泛型是一样的,可以传入任何杂七杂八的类型,接收时强制类型转换以及可能抛出ClassCastException异常.
那这么写干嘛?闲的?
- 当然不是,这样做的一个很重要的原因就在于—历史兼容性.Java在jdk 5.0之前没有泛型,所有的集合都是原始类型,当Java引入泛型时,为了向后兼容,必须保留原始类型.若突然移除原始类型,所有旧代码就都无法编译了
- 教育意义,原始类型可以帮助开发者更好地理解类型擦除的工作原理,懂得权衡兼容性和类型安全.
- 处理特殊场景,在一些特殊场景下只能用原始类型.