理解数据背后的二进制
整数的二进制表示与位运算
1、负整数的二进制表示
负数的二进制表示就是对应的正数的补码表示,比如:
1)-1:1的原码表示是00000001,取反是11111110,然后再加1,就是11111111。
2)-2:2的原码表示是00000010,取反是11111101,然后再加1,就是11111110。
3)-127:127的原码表示是01111111,取反是10000000,然后再加1,就是10000001。
给定一个负数的二进制表示,要想知道它的十进制值,可以采用相同的补码运算。比如:10010010,首先取反,变为01101101,然后加1,结果为01101110,它的十进制值为110,所以原值就是-110。直觉上,应该是先减1,然后再取反,但计算机只能做加法,而补码的一个良好特性就是,对负数的补码表示做补码运算就可以得到其对应正数的原码,正如十进制运算中负负得正一样。
2、位运算
位运算有移位运算和逻辑运算。移位有以下几种。
1)左移:操作符为<<,向左移动,右边的低位补0,高位的就舍弃掉了,将二进制看作整数,左移1位就相当于乘以2。
2)无符号右移:操作符为>>>,向右移动,右边的舍弃掉,左边补0。
3)有符号右移:操作符为>>,向右移动,右边的舍弃掉,左边补什么取决于原来最高位是什么,原来是1就补1,原来是0就补0,将二进制看作整数,右移1位相当于除以2。
3、Q: 一个数组中有两个数出现了奇数次,其他数都出现了偶数次,怎么找到并打印这种数?
public static void findOddTimesNum(int[] arrs) {
int eor = 0;
int leftOdd = 0, rightOdd = 0;
for (int i : arrs) {
//找到两个出现次数为奇数次的数leftOdd^rightOdd 的亦或^之后的值 eor
eor ^= i;
}
//找到该值的最低位为1的值;目的是将数组分成两部分也即将leftOdd与rightOdd两个数分开
//然后在分别在leftOdd与rightOdd的那一组中做亦或操作,最后就得到了leftOdd与rightOdd的值
eor = (~eor + 1);
for (int i : arrs) {
if ((i & eor) != 0) {
leftOdd ^= i;
} else {
rightOdd ^= i;
}
}
System.out.println("leftOdd: " + leftOdd);
System.out.println("rightOdd: " + rightOdd);
}
小数的二进制表示
float f = 0.1f*0.1f;
System.out.println(f);
这个结果看上去,应该是0.01,但实际上,屏幕输出却是0.010000001,后面多了个1。看上去这么简单的运算,计算机怎么会出错了呢?
3.1、小数计算为什么会出错?
二进制只能表示那些可以表述为2的多少次方和的数,可以精确表示为2的某次方之和的数可以精确表示,其他数则不能精确表示。
为什么计算机中不能用我们熟悉的十进制呢?在最底层,计算机使用的电子元器件只能表示两个状态,通常是低压和高压,对应0和1,使用二进制容易基于这些电子元器件构建硬件设备和进行运算。如果非要使用十进制,则这些硬件就会复杂很多,并且效率低下。
System.out.println(0.1f+0.1f);
System.out.println(0.1f*0.1f);
第一行输出0.2,第二行输出0.010000001。按照上面的说法,第一行的结果应该也不对。其实,这只是Java语言给我们造成的假象,计算结果其实也是不精确的,但是由于结果和0.2足够接近,在输出的时候,Java选择了输出0.2这个看上去非常精简的数字,而不是一个中间有很多0的小数。在误差足够小的时候,结果看上去是精确的,但不精确其实才是常态。
二进制表示
十进制有科学记数法,比如123.45这个数,直接这么写,就是固定表示法,如果用科学记数法,在小数点前只保留一位数字,可以写为1.2345E2即1.2345× (10^2),即在科学记数法中,小数点向左浮动了两位。
二进制中为表示小数,也采用类似的科学表示法,形如m× (2^e)。m称为尾数,e称为指数。指数可以为正,也可以为负,负的指数表示那些接近0的比较小的数。在二进制中,单独表示尾数部分和指数部分,另外还有一个符号位表示正负。
几乎所有的硬件和编程语言表示小数的二进制格式都是一样的。这种格式是一个标准,叫做IEEE 754标准,它定义了两种格式:一种是32位的,对应于Java的foat;另一种是64位的,对应于Java的double。
32位格式中,1位表示符号,23位表示尾数,8位表示指数。64位格式中,1位表示符号,52位表示尾数,11位表示指数。
4、char的真正含义
Java中进行字符处理的基础char, Java中还有Character、String、StringBuilder等类用于文本处理,它们的基础都是char
在Java内部进行字符处理时,采用的都是Unicode,具体编码格式是UTF-16BE。简单回顾一下,UTF-16使用两个或4个字节表示一个字符,Unicode编号范围在65536以内的占两个字节,超出范围的占4个字节,BE就是先输出高位字节,再输出低位字节,这与整数的内存表示是一致的。
char本质上是一个固定占用两个字节的无符号正整数,这个正整数对应于Unicode编号,用于表示那个Unicode编号对应的字符。由于固定占用两个字节,char只能表示Unicode编号在65 536以内的字符,而不能表示超出范围的字符。那超出范围的字符怎么表示呢?使用两个char。类Character、String有一些相关的方法
由于char本质上是一个整数,所以可以进行整数能做的一些运算,在进行运算时会被看作int,但由于char占两个字节,运算结果不能直接赋值给char类型,需要进行强制类型转换,这和byte、short参与整数运算是类似的。char类型的比较就是其Unicode编号的比较。
既然char本质上是整数,查看char的二进制表示,同样可以用Integer的方法,如下所示:
char c = '虎';
Syatem.out.println(Integer.toBinaryString(c));
ENUM枚举的本质
枚举类型都有一个静态的valueOf(String)方法,可以返回字符串对应的枚举值
System.out.println(Size.SMALL == Size.valueOf("SMALL"));
枚举类型也都有一个静态的values方法,返回一个包括所有枚举值的数组,顺序与声明时的顺序一致
枚举是怎么实现的呢?枚举类型实际上会被Java编译器转换为一个对应的类,这个类继承了Java API中的java.lang.Enum类。Enum类有name和ordinal两个实例变量,在构造方法中需要传递,name()、toString()、ordinal()、compareTo()、equals()方法都是由Enum类根据其实例变量name和ordinal实现的
泛型
1、基本原理
我们知道,Java有Java编译器和Java虚拟机,编译器将Java源代码转换为.class文件,虚拟机加载并运行.class文件。对于泛型类,Java编译器会将泛型代码转换为普通的非泛型代码,就像上面的普通Pair类代码及其使用代码一样,将类型参数T擦除,替换为Object,插入必要的强制类型转换。Java虚拟机实际执行的时候,它是不知道泛型这回事的,只知道普通的类及代码。
再强调一下,Java泛型是通过擦除实现的,类定义中的类型参数如T会被替换为Object,在程序运行过程中,不知道泛型的实际类型参数,比如Pair,运行中只知道Pair,而不知道Integer。认识到这一点是非常重要的,它有助于我们理解Java泛型的很多限制。
2、泛型的好处
既然只使用普通类和Object就可以,而且泛型最后也转换为了普通类,那为什么还要用泛型呢?或者说,泛型到底有什么好处呢?泛型主要有两个好处:
❑ 更好的安全性。
❑ 更好的可读性。
语言和程序设计的一个重要目标是将bug尽量消灭在摇篮里,能消灭在写代码的时候,就不要等到代码写完程序运行的时候。只使用Object,代码写错的时候,开发环境和编译器不能帮我们发现问题,看代码:
Pair pair = new Pair("虎",1);
Integer id = (Integer)pair.getFirst();
String name = (Integer)pair.getSecond();
看出问题了吗?写代码时不小心把类型弄错了,不过,代码编译时是没有任何问题的,但运行时程序抛出了类型转换异常ClassCastException。如果使用泛型,则不可能犯这个错误,比如下面的代码:
Pair<String,Integer> pair = new Pair("虎",1);
Integer id = (Integer)pair.getFirst(); //有编译错误
String name = (Integer)pair.getSecond();//有编译错误
编译时Java编译器也会提示。这称之为类型安全,也就是说,通过使用泛型,开发环境和编译器能确保不会用错类型,为程序多设置一道安全防护网。使用泛型,还可以省去烦琐的强制类型转换,再加上明确的类型信息,代码可读性也会更好。
3、类型参数的限定
在之前的介绍中,无论是泛型类、泛型方法还是泛型接口,关于类型参数,我们都知之甚少,只能把它当作Object,但Java支持限定这个参数的一个上界,也就是说,参数必须为给定的上界类型或其子类型,这个限定是通过extends关键字来表示的。这个上界可以是某个具体的类或者某个具体的接口,也可以是其他的类型参数
-
上界为某个具体类
比如,上面的Pair类,可以定义一个子类NumberPair,限定两个类型参数必须为Number,代码如下:
public class NumberPair<U extends Number,V extends Number> extends Pair<U,V>{ public NumberPair(U first,V second){ super(first,second); } } -
上界为某个接口
在泛型方法中,一种常见的场景是限定类型必须实现Comparable接口,我们来看代码:
public static <T extends Comparable> T max(T[] arr){ T max = arr[0]; for(int i=0;i<arr.length;i++){ if(arr[i].compareTo(max)>0){ max = arr[i]; } } return max; }max方法计算一个泛型数组中的最大值。计算最大值需要进行元素之间的比较,要求元素实现Comparable接口,所以给类型参数设置了一个上边界Comparable, T必须实现Comparable接口。
不过,直接这么编写代码,Java中会给一个警告信息,因为Comparable是一个泛型接口,它也需要一个类型参数,所以完整的方法声明应该是:
public static <T extends Comparable<T>> T max(T[] arr){ //主体代码 }<T extends Comparable>是一种令人费解的语法形式,这种形式称为递归类型限制,可以这么解读:T表示一种数据类型,必须实现Comparable接口,且必须可以与相同类型的元素进行比较。
-
上界为其他类型参数
DynamicArray<Number> numbers = new DynamicArray<>(); DynamicArray<Integer> ints = new DynamicArray<>(); ints.add(2022); ints.add(2021); numbers.addAll(ints); //会提示编译错误numbers是一个Number类型的容器,ints是一个Integer类型的容器,我们希望将ints添加到numbers中,因为Integer是Number的子类,应该说,这是一个合理的需求和操作。
但Java会在numbers.addAll(ints)这行代码上提示编译错误:addAll需要的参数类型为DynamicArray,而传递过来的参数类型为DynamicArray,不适用。Integer是Number的子类,怎么会不适用呢?
事实就是这样,确实不适用,而且是很有道理的,假设适用,我们看下会发生什么。
DynamicArray<Integer> ints = new DynamicArray<>(); DynamicArray<Number> numbers = ints; //假设这是合法的 numbers.add(new Double(2022.1));那最后一行就是合法的,这时,DynamicArray中就会出现Double类型的值,而这显然破坏了Java泛型关于类型安全的保证。
我们强调一下,虽然Integer是Number的子类,但DynamicArray并不是DynamicArray的子类,DynamicArray的对象也不能赋值给Dynamic-Array的变量,这一点初看上去是违反直觉的,但这是事实,必须要理解这一点。
不过,我们的需求是合理的,将Integer添加到Number容器中并没有问题。这个问题可以通过类型限定来解决:
public void addAll(DynamicArray<? extends E> c){ for(int i=0;i<c.size;i++){ add(c.get(i)); } }这个方法没有定义类型参数,c的类型是DynamicArray<? extends E>, ?表示通配符,<? extends E>表示有限定通配符,匹配E或E的某个子类型,具体什么子类型是未知的。使用这个方法的代码不需要做任何改动,还可以是:
DynamicArray<Number> numbers = new DynamicArray<>(); DynamicArray<Integer> ints = new DynamicArray<>(); ints.add(2022); ints.add(2021); numbers.addAll(ints);这里,E是Number类型,DynamicArray<? extends E>可以匹配DynamicArray 。那么问题来了,同样是extends关键字,同样应用于泛型,和<?extends E>到底有什么关系?它们用的地方不一样,我们解释一下:
- 1)用于定义类型参数,它声明了一个类型参数T,可放在泛型类定义中类名后面、泛型方法返回值前面。
- 2)<? extends E>用于实例化类型参数,它用于实例化泛型变量中的类型参数,只是这个具体类型是未知的,只知道它是E或E的某个子类型。
\