[Java Tutorials] 03 | Java Langu

2018-12-12  本文已影响6人  夏海峰

数值 与 字符串

关于 Java 数值,要学习哪些东西?

本章将讨论并学习 java.lang.Number 类及其子类。尤其要讨论,在什么情况下应该使用数值实例对象而非原始数据类型。另外,还将讨论一些你可能需要的与数据相关的类,比如数值格式、数学公式等。最后,将讨论一下自动装箱和自动拆箱,编译器支持的这一特征将有利于简化你的代码。

关于 Java 字符串,要学习哪些东西?

字符串,在 Java编程语言中被广泛使用。字符串,由一串字符组成。字符串也是对象。本章将讨论 String 类、如何操作字符串,以及 String 和 StringBuilder 之间的异同。

Java 数值 的学习目标

Java 数值相关的类

和数值打交道,大多数时候你使用的都是原始数据类型。然而,有时候你需要使用数值对象来代替原始数据类型。这种对原始数据类型的包装,对包装类型的折包,通常是由Java编译器自动来完成的。常用的数值类,如下图示:

objects-numberHierarchy.gif

除了上述图示中常用的数值类,还有四个有用的数值类,分别是 BigDecimal / BigInteger / AtomicLong / AtomicInteger 。

在哪些场景下,需要使用数值对象而非原始数据类型呢?

  1. 当方法的参数希望是对象时,通常用于对数值集合的操作。
  2. 当需要使用数值类中定义的常量,如 MIN_VALUE / MAX_VALUE时,用于给数据类型提供一个范围边界。
  3. 当需要做数据类型转化时,如原始数据类型之间的转换、数值与字符串之间的转换等。

关于数值类有很多 API,也很有用,详情参见 Java API 手册。

数值的 格式化输入 与 格式化输出

你已经使用过 print / println 打印字符串,即标准输出。由于所有的数值类型都可以被转换成字符串,所以你也可以使用 System.out 来输出数值和字符串的混合体。在Java中,还有很多有用的方法允许你在包含数值的标准输出中进行更多的控制。比如,使用 format() / printf() 方法对数值输出进行格式化。

public PrintStream format();
public PrintStream printf();

long n = 461012;
System.out.format("%d%n", n);      //  -->  "461012"
System.out.format("%08d%n", n);    //  -->  "00461012"

Calendar c = Calendar.getInstance();
System.out.format("%tB %te, %tY%n", c, c, c); // -->  "May 29, 2006"

我们还可以使用 java.text.DecimalFormat 类来控制数头尾处零的个数,以“千”进行组织分隔,对小数点进行组织分隔等。DecimalFormat类提供了很多灵活的方法对数值进行格式化,但是这会使得你的代码更加复杂。

import java.text.*;
public class TestFormat {
    public static void customFormat(String pattern, double value) {
        DecimalFormat my = new DecimalFormat(pattern);
        String output = my.format(value);
        System.out.println(value + " " + pattern + " " + output);
    }
    public static void main(String[] args) {
        customFormat("###,###.###", 123456.789);
        customFormat("###.##", 123456.789);
        customFormat("000000.000", 123.78);
        customFormat("$###,###.###", 12345.75);
    }
}

Math类 与 更复杂的数学运算

Java语言支持最基本的四则运算符,如加减乘除。java.lang.Math 提供了更多的方法和常量以满足更加高级的数学计算。Math类中,所有的方法都是静态方法,所以你无须创建对象,直接使用 Math 即可调用这些方法。
注意:使用 static import 静态导入这一特性,可以让你无须使用 Math来调用它的方法。示例如下:

import static java.lang.Math.*;   // 静态导入
cos(angle); // 静态导入后,可以省略 Math

Math类有两个常量,分别是 Math.PI / Math.E 。在Math类中,还有 40多个静态方法,详情见 Java API 手册。

System.out.printf("The floor of " + "%.2f is %.0f%n",  b, Math.floor(b));

Math 还支持指数运算、对数运算。

System.out.printf("exp(%.3f) " + "is %.3f%n", x, Math.exp(x));
System.out.printf("sqrt(%.3f) is " + "%.3f%n", x, Math.sqrt(x));

Math 还支持三角函数运算。

System.out.format("The arccosine of %.4f " + "is %.4f degrees %n",  Math.cos(radians),  Math.toDegrees(Math.acos(Math.cos(radians))));

Math 还支持随机数。

double r = Math.random();
int number = (int)(Math.random() * 10);

注:Math.random() 返回一个不小于零且小于一的随机数。如果你需要生成一系列的随机数,请使用 java.util.Random 类。

Java 数值 小结
你可以使用Java数值的包装对象,Java虚拟机会在你需要的时候对它们进行自动装箱和拆箱。你可以使用 Number类中常量和方法。你可以使用 PrintStream 类中的 printf() / format() 方法对数值进行格式化输出,或者使用 NumberFormat 类的 pattern 模式来自定义数值格式。你可使用 Math 类中的静态方法来实现更为复杂的数学计算。

Java 字符
有些时候,如果你用到单一字符,你可以使用 char 这一原始数据类型。

char a = 'a';
char b = '\u03A9';

有些时候,你需要用到字符对象。Java 提供了一个char 类型的包装对象 Character ,它提供一些有用的方法用于操作单个字符。

Character ch = new Character('a');

Java编译器在一些场景下会自动为你创建字符的包装对象。比如,当你用 char 原始类型去调用 Character中的方法时,这个时候编译器会自动地帮你把 char 类型转化成 Character 包装类。这一特性被称自动装箱与拆箱。

注意,Character 类具有不可变性,一旦创建了 Character 包装对象,则这个对象将不能被改变。更多有关 Character 方法,请参见 Java API 手册。

Java转义字符以反斜杠开头,转义字符之于 Java编译器有特殊意义,在输出语句中当编译器遇到转义字符时,编译器会做相应的处理。

System.out.println("She said \"Hello\" to me.");

Java 字符串

字符串,在Java中被广泛使用。字符串,是对象。字符串是有序列的一串字符。Java语言提供了 String 类,用于创建字符串以及字符串操作。

String greet = "Hello World";

字符串,用双引号包裹。在 Java中,无论何时,编译器遇到字符串时都会将其字面值创建为 String 对象。除此之处,你还可以使用 new 关键字来调用 String 的构造器以创建字符串对象。String 类有三个构造器,比如使用字符数组来创建字符串对象。

char[] helloArray = { 'h', 'e', 'l', 'l', 'o' };
String helloString = new String(helloArray);

字符串具有不可变性,因此一旦创建字符串对象,则不能再改变它。String 类同样有着一系列好用的方法,由于字符串对象具有不可变性,所以字符串对象的操作实际上是创建并返回了一个新的字符串对象,而非改变操作之前的字符串对象。

// 字符串的长度
String name = "geek xia";
int len = name.length();

对字符串进行循环遍历,可以实现字符串翻转、回文等。charAt() 可以获取到指定字符的下标。getChars() 方法可以把字符串对象转化成字符数组。concat() 可以用来拼接两个字符串,使用 + 运算符也可以拼接字符串。

注意:Java语言不支持在源码中把连续的字符串折断成多行。所以,当连续的字符串被折断时,你需要使用 + 操作符将其拼接起来,这在输出语句中会经常用到,示例如下:

String quote = 
    "Now is the time for all good " +
    "men to come to the aid of their country.";

你已经知道,使用 printf() / format() 可以数值进行格式化输出。在 String中,使用 String的静态方法 format() 也可以实现字符串的格式化操作,该方法会返回一个新的字符串对象,因此这种格式化后的字符串对象是可以复用的,而不是一次性的打印。

String fs;  // fs 可复用
fs = String.format("The value of the float variable is %f, while "+
    "the value of the integer variable is %d, " +
    "and the string is %s", floatVar, intVar, stringVar);

数值 和 字符串 之间的相互转换
如何把字符串转换成数值?

Number类及其子类,都有 valueOf() / parseXXXX() 方法,使用这两个方法之一都可以把字符串对象转换成数值的包装类型。如下示例,程序接收命令行输入并将其转化为数值、进而进行数学运算。

public class ValueDemo {
    public static void main(String[] args) {
        if (args.length == 2) {
            float a = (Float.valueOf(args[0])).floatValue();
            float b = (Float.valueOf(args[1])).floatValue();
            System.out.println(a + " + " + b + " = " + (a+b));
        } else {
            System.out.println("This program requires two command-line arguments.");
        }
    }
}
$ java ValueDemo 100 500      // 给 main()方法传递两个参数

如何把数值转换成字符串?
有时候我们需要使用数值的字符串形式。有很多种方式可以把数值转换成字符串。

int i;
String str = "" + i;    // 让数值与空串拼接
String str = String.valueOf(i); // 使用 String.valueOf() 这一静态方法

Number类及其子类,都包含一个 toString() 方法,使用它可以把原始数据类型转换为字符串。

double d;
String str = Double.toString(d);

操作字符串中的字符

String类中有很多方法,可用于对字符串内容进行检测,从中查找某个字符或者子串,执行相关处理和操作。

charAt() 用于获取某个字符在字符串中的下标,字符串下标从 0 开始,最后一个字符的下标是 length - 1 。
substring() 用于从字符串中获取一个子串。
indexOf() 用于从字符串中搜索一个字符或者子串,如果搜索到则返回位置下标,如果没有搜索到则返回 -1。
lastIndexOf() 用于从字符串的末尾开始搜索一个字符或子串,如果搜索到则返回位置下标,如果没有搜索到则返回 -1。
contains() 用于检测字符串中是否包含指定的字符或者子串,返回布尔值。

String类中还有几个方法,可用于向字符串中插入一个字符或者另一个字符串。更多方法,请参见 Java API 手册。

字符串之间的比较

String类中还有一部分方法可以用于字符串之间的比较。下面示例,对字符串进行搜索匹配。

public class RegionMatchDemo {
    public static void main(String[] args) {
        String searchMe = "Green Eggs and Ham";
        String findMe = "Eggs";
        int searchMeLength = searchMe.length();
        int findMeLength = findMe.length();
        boolean foundIt = false;
        for (int i = 0; i <= (searchMeLength - findMeLength); i++) {
            if (searchMe.regionMatches(i, findMe, 0, findMeLength)) {
                foundIt = true;
                System.out.println(searchMe.substring(i, i+findMeLength));
                break;
            }
        }
        if (!foundIt) {
            System.out.println("No match found.");
        }
    }
}

StringBuilder类

StringBuilder 对象类似于 String对象,但最重要的区别在于前者是可以被修改的。在 StringBuilder 对象的内部,它们被视为一个字符数组,且这个数组的长度与字符串长度相等。在任何时候,StringBuilder 对象的长度和内容都可以被改变。
通常情况下,我们应该使用 String字符串,除非当 StringBuilder 能提供更简洁的代码或者更好的性能时。比如,当你需要拼接很多个字符串时,使用 StringBuilder将是一个更好的选择(把其它字符串追加到 StringBuilder字符串上)。

StringBuilder 类,也有一个 length() 方法,用于返回字符序列的长度。和 String不同的是,StringBuilder 对象还有一个 capacity容量属性,它代表着被分派的字符空间。调用 StringBuilder 的 capacity() 方法会返回一个比 length 更大的数值,即字符串“容量”。并且这个“容量”在需要的时候,还会自动地扩容以容纳更多的字符。

如何创建 StringBuilder 对象?

StringBuilder sb = new StringBuilder(); // 容量默认是 16
sb.append("Greetings"); // 向 StringBuilder 字符串中添加 9 个字符

上述代码,创建了一个 length = 9 / capacity = 16 的 StringBuilder 对象。

StringBuilder 还有一些与“长度”和“容量”有关的方法,这些方法在 String中是不存在的。比如 setLength() / ensureCapacity() 等。

注:当使用 append() / insert() / setLength() 等方法操作 StringBuilder 对象时,可能会导致 StringBuilder对象的 length 大于 capacity,其实并不用担心这个问题,因为 StringBuilder对象会根据 length 的变化自动地扩容,终归 capacity 还是大于等于 length 。

StringBuilder 中最重要的且 String 不支持的方法是 append() / insert() 。这两个方法可以将其参数转换成字符串,追加在 StringBuilder对象的末尾或者插入到指定位置。

那么,StringBuilder对象和 String对象之间怎么相互转化呢?

你可以使用 StringBuilder 的 toString() 方法把 StringBuilder对象转换成 String对象。你可以使用 new StringBuilder( String str ) 把字符串转换为 StringBuilder 对象。

下面示例,演示了一个使用 StringBuilder 比 String 更高效的例子:对回文进行翻转。为什么在这种情况下使用 StringBuilder 会更高效呢?因为 StringBuilder 有 reverse() 方法,可以对字符串进行改变。

public class StringBuilderDemo {
    public static void main(String[] args) {
        String str = "Dot saw I was Tod";
        StringBuilder sb = new StringBuilder(str);
        sb.reverse();   // 直接改变 StringBuilder 字符串
        System.out.println(sb);
    }
}

当用 println() 向控制台输出 StringBuilder 对象时,StringBuilder对象会默认调用 toString()方法先转换成 String对象,然后才输出至控制台。

注:还有一个 StringBuffer 类,它和 StringBuilder类几乎一样,除非当Java虚拟机同步执行以保证线程安全时。

Java 字符 和 字符串 小结

char类型 与 Character 包装对象。在 Java中,String类有60个多个方法,有13个构造器。StringBuilder 在某些场景下会比 String更加高效,StringBuilder 拥有一系列的方法可以对其进行修改操作。StringBuilder 对象 和 String 对象之间可以相互转换。

自动装箱 与 自动拆箱

所谓的自动装箱,是指Java编译器自动地把 原始数据类型转换成 相应的包装类型。自动拆箱,是相反的过程。

List<Integer> list = new ArrayList<>();
for (int i = 0; i<50; i++) {
    list.add(i);
}

在上述代码中,看上去你是向集合中添加了 int 类型的原始类型数据,实际上在代码编译之后你添加的却是 Integer 类型。因为 list 是一个 Integer 对象类型的集合,而不是 int 类型。你会好奇上述代码在编译时为什么不报错?编译器之所以没有报错,是因为编译器自动创建了 Integer 对象并将它们添加到了 list 集合中。上述代码,在编译运行时,被转换成了如下代码:

List<Integer> list = new ArrayList<>();
for (int i = 0; i<50; i++) {
    list.add(Integer.valueOf(i));   // Integer类型
}

这即,自动装箱

再看下面代码,看看自动拆箱又是怎么个过程?

public static int sumEven(List<Integer> list) {
    int sum = 0;
    for (Integer i: list) {
        if (i % 2 == 0) {
            sum += i;
        }
    }
    return sum;
}

上述代码,sumEven() 方法的参数是一个 Integer类型元素的集合,那么使用一元运算符 % 对 Integer 类型进行操作,代码在编译时不会报错吗?实际上是不会报错的,因为编译器在运行时会默认调用包装类型的 intValue() 方法把包装类型转化为原始数据类型。编译运行时,上述代码会被转化成如下代码:

public static int sumEven(List<Integer> list) {
    int sum = 0;
    for (Integer i : list) {
        if (i.intValue() % 2 == 0) {
            sum += i.intValue();
        }
    }
    return sum;
}

这即,自动拆箱

自动装箱和自动拆箱,使得开发者可以书写更加简洁的代码,增强了代码的可读性。下表,是原始数据类型与包装类型的对应表。

装箱与拆箱.png

Java 泛型

Java泛型,是Java语言一个非常强大的特性。泛型可以大大地提升Java代码的类型安全,使得更多的 bug 在编译时就能被暴露出来。

在软件工程中,bug 简直就和现实生活一样。周全的设计、编程和测试能减少 bug 的大面积出现,但是无论如何,它们总会找到一种方式蔓延到你的代码中去。随着软件项目的规模和复杂性的增加,bug 将更加明显的出现。幸运的是,有很多 bug 是非常容易发现并清除的。编译运行时的 bug,就能够更早地被发现并清除,你可以利用编译器的报错信息去定位 bug在哪里并修复它们。然而,运行时的 bug 相对编译时 bug 就显得更加可怕了,因为运行时 bug 通常不会立即浮现在眼前。当程序执行时,bug 可以出现在某一点上,却与实际的问题原因相差甚远。

泛型使得 bug 在程序编译时就能被发现,这极大地增强了代码的稳定性。学完本章内容,你或许就很想把泛型探究到底。

为什么要使用 泛型?

当定义类、接口或者方法时,泛型能够在极小的范围内规定参数类型。这种方法中的形参非常相似,给参数指定类型为方法的复用提供了可能。不同的是,正式输入的实参是参数的值,而形参却是参数的数据类型。使用了泛型的代码,有如下优势:

  1. 更加强调了编译时的类型检测。Java编译器会对使用了泛型的代码执行强类型检测,如果代码违反了安全类型就会在编译时报错。修复编译时的 bug,比运行时bug更加容易。
  2. 使用泛型,程序员可以实现良好的泛型系统,这允许你在定义集合时为其指定自定义的数据类型,以保证类型安全,并且可读性更强。
  3. 使用泛型可以消除强制类型转换。看下面代码,使用泛型时,可以避免强制类型转换。
不使用泛型时:
List list = new ArrayList();
list.add("hello");
String s = (String)list.get(0);   // 强制类型转换
使用泛型,良好地避免了强制类型转换:
List<String> list = new ArrayList<String>();
list.add("hello");
String s = list.get(0);

泛型 类型

泛型类型,即用于数据类型参数化时所指定的类或接口。下面代码中的 Box 类,即可说明这一概念。

class Box<T1, T2, T3, ..., Tn> {  }

参数类型区域,使用尖括号 < > 界定边界,并且紧跟在类名之后,它指定了该类的参数类型只能是 T1, T2, T3, ..., Tn 。

// <T> 泛型
public class Box<T> {
    private T t;
    public void set(T t) {
        this.t = t;
    }
    public T get() {
        return t;
    }
}

上述代码中的 T ,被称为类型变量,在类内部的任意地方都可以使用它。正如你看到的,这个类型变量 T 可以是任意的非原始数据类型,它可以是任意的类、接口、数组类型、或者是另一个类型变量。
上述代码中这种给类指定泛型的技术,也适用于接口,即给接口定义指定泛型。

类型参数的命名约定:类型参数建议使用单个、大写字母。这与普通的变量命名约定完全不同。只有遵守了这一约定,我们的类型参数名才会与类名、接口名区分开来。以下规则,你在 Java API 文档中会经常看到。

E - Element
K - Key
V - Value
N - Number
T - Type
S, U, V etc. - 2nd, 3rd, 4th types

如何调用和实例化一个泛型类型?
为了引用一个泛型类型的 Box,你必须执行泛型调用,即把泛型类型参数 T 替换成实参,比如 Integer。

Box<Integer> integerBox;    // 实际调用,传递实参

你可以泛型调用类比成方法调用,方法调用时用实参替换形参,泛型调用也同理,需要把类型参数 T 等替换成实际类型。

Type Parameter 和 Type Argument 有什么异同?
许多开发者,会把 Type Parameter / Type Argument 这两个概念混淆,实际上它们并不是同一件事儿。当编码时,我们使用 Type Argument 创建一个参数化的类型。因此,Foo<T> 中的 T 是 Type Parameter (形式上的参数类型)。 Foo<String> 中的 String 是 Type Argument (实际上的参数类型)。

integerBox = new Box<Integer>();

在Java7.0 之后,上述的关于泛型类型的实例化和调用,还可以简写成这样:

Box<Integer> integerBox = new Box<>();

当简化了泛型类型参数时,Java编译器会从声明类型中获取泛型类型。

了解了泛型类型的基础使用后,让我们再看看“多个泛型类型参数”的使用场景:

// 指定了泛型的接口
interface Pair<K, V> {
    public K getKey();
    public V getValue();
}

// 指定了泛型的类
public class OrderedPair<K, V> implements Pair<K, V> {
    private K key;
    private V value;
    public OrderedPair(K key, V value) {
        this.key = key;
        this.value = value;
    }
    public K getKey() { return key; }
    public V getValue() { return value; }

    public static void main(String[] args) {
        // 调用泛型类型并实例化
        Pair<String, Integer> p1 = new OrderedPair<String, Integer>("Even", 8);
        Pair<String, String> p2 = new OrderedPair<String, String>("Hello", "World");
        // 省略 泛型参数 时
        Pair<String, Boolean> p3 = new OrderedPair<>("flag", false);

        System.out.println(p3.getValue());
    }
}

在调用泛型类型时,你还可以把泛型类型作为实参传递至泛型类型中,如下:

OrderedPair<String, Box<Integer>> p4 = new OrderedPair<>("primes", new Box<Integer>());

泛型类型的原始类型 Raw Types

什么是 Raw Type (泛型类型的原始类型) ?Raw Type,即没有携带泛型参数的泛型类型的类或者接口。示例如下:

public class Box<T> {
    public void set(T t) { }
}

// Raw Types
Box rawBox = new Box();

上述代码,就演示了 Raw Types。 即,Box 就是 Box<T> 的原始类型。这里请注意,没有指定泛型类型的类或接口,是没有原始类型的。

之所以存在原始类型这个东西,是因为在 JDK5.0 之前,很多 Java类是没有使用泛型的。当你使用原始类型时,你本质上只能使用指定泛型之前的对象行为。考虑到向后兼容的能力,Java允许你把泛型类型对象赋值给原始类型对象,反之则不行。示例代码如下:

Box<String> stringBox = new Box<>();
Box rawBox = stringBox; // OK,允许这样做

rawBox.set("hello");    //  warning,不允许原始类型对象去调用泛型类型对象的方法

Box<String> stringBox2 = rawBox;    // waring,不允许把原始类型对象赋值给泛型类型对象

上述代码展示了原始类型可以避开泛型类型的类型检测,但在这运行时环境下是不安全的,因此我们应该尽量避免使用泛型类型的原始类型。

泛型方法

泛型方法,即拥有自己的泛型参数的方法。泛型方法和泛型类的声明很相似,只是它们的作用域不同而已。在类中,除了构造器以外,所有的方法都可以被声明成泛型方法,包括静态泛型方法和非静态泛型方法。泛型方法的语法,用一对尖括号把泛型参数列表包裹起来,放在返回值类型的前面(必须是在返回值类型的前面)。示例如下:

public class Util {
    // 泛型方法
    public static <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) {
        return p1.getKey().equals(p2.getKey()) && p1.getValue().equals(p2.getValue());
    }
    // 测试
    public static void main(String[] args) {
        Pair<Integer, String> p1 = new Pair<>(1, "hello");
        Pair<Integer, String> p2 = new Pair<>(2, "world");

        // 以下两种写法,都是正确的。泛型方法的泛型可以省略
        boolean bol1 = Util.<Integer, String>compare(p1, p2);
        boolean bol2 = Util.compare(p1, p2);
        System.out.println(bol1 + " - " + bol2);
    }
}

class Pair<K, V> {
    private K key;
    private V value;
    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }
    public K getKey() { return key; }
    public V getValue() { return value; }
}

对参数类型进行限制

有时候,你希望在参数化的参数类型中对参数类型进行限制。比如说,一个用于数学运算的方法希望接收的参数类型是 Number 及其子类类型。这就是所谓的“对参数类型进行限制”。

如何声明一个可以限定参数类型的泛型方法呢?
使用 extends 关键字,将要限定的参数类型紧跟其后即可。示例如下:

public class Box<T> {
    // 限定泛型方法的参数 u 只能是 Number 及其子类类型
    public <U extends Number> void inspect(U u) {
        System.out.println("U: " + u.getClass().getName());
    }
}

不仅可以对泛型方法的参数类型进行限定,还可以泛型类型的类进行泛型参数限定。示例如下:

public class NaturelNumber<T extends Integer> {
    private T n;
    public NaturelNumber(T n) {
        this.n = n;
    }
    public boolean isEven() {
        return n.intValue() % 2 == 0;
    }
}

上述代码中,使用 extends 关键字,限定了泛型类型 NaturelNumber<T> 的参数类型只能是 Integer及其子类。isEven() 方法中调用的 intValue() 就来自被限定类型 Interger。

对类型参数限定,还可以指定多个类型的限定,多个被限定的类型之间用 逻辑运算符 进行连接。示例如下:

class XXX<T extends Class1 & Interface2 & Interface3> { }

注意:当有多个限定边界时,类应该放在接口之前,如上代码,Class1 必须在 Interface1 / Interface2 之前,否则编译时,会报错。

泛型方法与参数类型限定
有界参数类型是泛型算法实现的关键。下面方法,用于查找一个数组中比指定值大的元素有多少个:

public static <T> int countGreaterThan(T[] anArr, T ele) {
    int count = 0;
    for (T e : anArr) {
        if (e > ele) count++;
    }
    return count;
}

编译上述代码时,会报错。因为 > 只能用于原始数据类型 short / int / double / long / float / byte / char 。为了修复这个问题,代码要改造如下:

interface Comparable<T> {
    public int compareTo(T o);
}

public static <T extends Comparable<T>> int countCreaterThan(T[] anArr, T ele) {
    int count = 0;
    for ( T e : anArr ) {
        if (e.compareTo(ele) > 0) count++;
    }
    return count;
}

上述代码演示了“如何使用参数类型边界来解决编译时错误”。

泛型继承 与 子类型

正如你所知,我们可以给某种类型的变量指派另一种兼容性的数据类型。比如,我们以把 Integer类型的变量,赋值给 Object 类型的变量,因为 Object 是 Integer 的间接父类。代码示例如下:

Object someObj = new Object();
Integer someInt = new Integer(100);
someObj = someInteger;  // OK

在面向对象的编程语言中,上述代码中的这种类之间的关系,被称为“is-a”关系,即 “Integer is a kind of Object”。这种“is-a”的兼容关系,在泛型系统中同样适用,看如下代码:

Box<Number> box = new Box<>();
box.add(new Integer(100));  // OK
box.add(new Double(0.5));   // OK

上述代码没有问题,因为 Integer / Double 都是 Number 的子类。

再看看下面这段代码:

public void boxTest(Box<Number> n) { }

试想一下,这个 boxTest() 方法是否可以接受 Box<Integer> 或者 Box<Double> 作为参数呢?答案是“不能”。那这是为什么呢?因为 Box<Integer> 和 Box<Double> 并不是 Box<Number> 的子类型(见如下图示)。这在泛型系统中是一个非常容易犯错的地方,但这又是一个非常重要的概念。

generics-subtypeRelationship.gif
从上面的分析,我们可以得知:MyClass<A> 和 MyClass<B> 没有任何关系,无论 A 和 B 是什么关系。MyClass<A> 和 MyClass<B> 唯一的相同点是它有着共同的父类 Object。

你可以使用 extends / implements 关键字来创建泛型类或泛型接口的子类。两个泛型类或泛型接口的类型参数之间的关系决定于 extends / implements 子句。

举例说明,ArrayList<E> 实现了 List<E> 接口,而 List<E> 又继承自 Collection<E> 。所以,ArrayList<String> 是 List<String> 的子类, List<String> 又是 Collection<String> 的子类。只要不改变参数的数据类型,这种继承关系就会被保留下来。

generics-sampleHierarchy.gif
再次强调:泛型类型之间的继承关系,与泛型参数类型之间的继承关系没有任何关系。

类型推断

类型推断,是Java编译器根据每个方法调用及其声明来决定参数类型从而使得方法调用更加合理的一种能力。这种推断算法决定了参数类型,如果可用,则该推断算法会把类型结果分派出去,或者返回。另外,这种推断算法会找到最具体的类型以适配所有的参数。

看下面示例,Java虚拟机的推断算法的结果是:把 pick() 方法的第二个参数传递给了 Serializable 类型。

static <T> T pick( T a1, T a2) { return a2; }
Serializable s = pick("d", new ArrayList<String>());

类型推断在泛型方法中的应用:
泛型方法的类型推断,使你能够像调用没有用尖括号指定类型的普通方法一样去调用泛型方法。看下方示例:

public class BoxDemo {
    public static <U> void addBox(U u, java.util.List<Box<U>> boxes) {
        Box<U> box = new Box<>(u);
        box.set(u);
        boxes.add(box);
    }
    public static <U> void outputBoxes(java.util.List<Box<U>> boxes) {
        int counter = 0;
        for ( Box<U> box: boxes ) {
            U boxContents = box.get();
            System.out.println("Box # " + counter + "contains [" + boxContents.toString() + "]");
            counter++;
        }
    }
    public static void main(String[] args) {
        java.util.ArrayList<Box<Integer>> listOfIntegerBoxes = new java.util.ArrayList<>();
        BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);
        // 基于“类型推断”后的简化
        BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);
        BoxDemo.addBox(Integer.valueOf(30), listOfIntegerBoxes);
        BoxDemo.outputBoxes(listOfIntegerBoxes);
    }
}

class Box<U> {
    private U content;
    public Box(U u) {
        this.content = u;
    }
    U get() { return content; }
    void set(U u) { this.content = u; }
}

在上述代码中,泛型方法 addBox() 定义了一种被命名为 U 的参数类型。一般来说,Java编译器可以推断出这个泛型方法调用时的参数类型。因此在大多数时候,你不需要特别地指出具体的参数类型,即省略掉泛型方法的类型限定。所以,在调用泛型方法 addBox() 时,如下的两种方式都可以被成功编译:

BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);
BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);

类型推断在泛型类实例化过程中的应用:

Map<String, List<String>> myMap = new Hash<String, List<String>>();
// 基于“类型推断”后的简化
Map<String, List<String>> myMap = new Hash<>();
// 但引用泛型的原始类型,就会 warning
Map<String, List<String>> myMap = new Hash();   // warning

类型推荐在泛型构造器中的应用:

class MyClass<X> {
    <T> MyClass(T t) { }
}
// 实例化
MyClass mc = new MyClass<Integer>();
MyClass mc = new MyClass<>();

通配符

在泛型代码中,问号 ? 被称为通配符,代表着未知的数据类型。通配符的使用场景有很多,可以用于参数类型、成员变量类型、局部变量类型,甚至还可以用于方法的返回值类型。但是,当泛型方法被调用时,不能用通配符作为实参类型进行参数传递,也不能用于类实例化时的参数传递。下面将对通配符进行更加与详细的介绍,包括上有界通配符、下有界通配符,通配符捕获等。

使用 上有界通配符:

public static void process(List<? extends Number> list) {  }

使用 没有边界的通配符:

public void printList(List<?> list) {  }

请注意,printList(List<?>) 并不等同于 printList(List<Object>) ,前者的 list参数中可以插入 null ,而后者的 list参数只能插入 Object及其子类实例。

使用 下有界通配符:

下有界通配符,使用 super关键字来指定。注意,上有界通配符和下有界通配符,不能同时作用于一个变量。

public void addNumber(List<? super Integer>) {  }

通配符与子类型

在前面章节中,我们已知道,两个泛型类或者泛型接口之间的关系与泛型参数之间的关系没有任何关系。但是在本节,我们要讲的是,使用通配符可以创建这种有关系的泛型类或泛型接口。
先做一个假设,假设 B extends A, 那么:

B b = new B();
A a = b;    // ok

List<B> lb = new ArrayList<>();
List<A> la = lb;    // 这将导致编译时错误

上述代码之所以报错,是因为 List<B> 并不是 List<A> 的子类,所以这种兼容式的赋值会报错。那么该怎样使用通配符来实现两个泛型类之间的继承关系呢?

List<? extends Integer> intList = new ArrayList<>();
List<? extends Number> numList = intList;   // ok

上述代码,是没有问题的。因为 List<? extends Integer> 是 List<? extends Number> 的子类型。Integer 是 Number的子类,numList 是 Number的集合,intList 是 Integer 的集合。这种使用了通配符的泛型类,它们的继承关系如下图所示:


generics-wildcardSubtyping.gif

通配符捕获 与 帮助方法

有时候,Java编译器会推断通配符的类型。比如,List<?> ,当评估这个表达式时,编译器会为这段代码推断一个特定的类型。这种情况,即被称为“通配符捕获”。

大多数时候,我们并不需要关心通配符捕获,除非编译器报出了“ WildcardError ”的错误时。比如,下面代码在编译时就会报 WildcardError 错误:

import java.util.List;
public class WildcardErr {
    void foo(List<?> list) {
        list.set(0, list.get(0));
    }
}

通配符的使用指南

学习泛型类的编程语言,难处之一便是如何在上有界通配符和下有界通符之间做选择。本小节将给出一些关于通配符的使用指南,以帮助你更多地设计你的代码。

为了方便讨论通配符的使用原则,我们先规范一下变量的两种功能:

  1. “In 型变量”,以 copy(src, dest) 方法为例,src 变量即被称为“In 型变量”。“In 型变量”用于端出数据。
  2. “Out 型变量”,以 copy(src, dest) 方法为例,dest 变量即被称为“Out 型变量”。“Out 型变量”用于承载数据,在任何可访问的地方被使用。
  3. 有些变量,既可以被看成“In 型变量”,也可以被看成“Out 型变量”。

我们可以根据变量的这种“In / Out”功能,对通配符进行规范使用。

  1. 当变量是"In 型变量"时,使用上边界通配符,用 extends 关键字。
  2. 当变量是"Out 型变量"时,使用下边界通配符,用 super 关键字。
  3. 当变量量"In 型变量",且能被类中的方法访问时,使用无边界的通配符。
  4. 当变量既是"In 型变量",又是"Out 型变量"时,不要使用通配符。

上述四个原则,不适用于方法的返回值类型。不要在方法返回值类型中使用通配符,这会强迫编程人员编写代码来处理通配符。

泛型类型擦除

Java中的泛型提供了编译时一种强大的类型检测功能,Java语言支持泛型编程。Java编译器应用泛型擦除,会做以下事情:

  1. 把泛型中所有参数类型都替换成边界类型,如果没有边界限制时就替换成 Object 类型。经编译得到的字节码中,只包含普通类、接口和方法。
  2. 如果需要保证类型安全,将插入强制类型转换。
  3. 创建桥接方法,用于保证继承而来的泛型类型的多态性。

在泛型类型擦除的过程中,Java编译器会擦除所有的类型参数,并将它们替换成相应的每一个边界类型,如果没有边界类型时就替换为 Object 。下面将举例说明:

// 源码
public class Node<T> {
    private T data;
    private Node<T> next;
    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }
    public T getData() {
        return data;
    }
}

上述源码中,类型参数 T 是没有边界的。所以经 Java 编译器执行类型擦除后,将得到如下结果:

// Java编译器执行类型擦除后
public class Node {
    private Object data;
    private Node next;
    public Node(Object data, Node next) {
        this.data = data;
        this.next = next;
    }
    public Object getData() {
        return data;
    }
}

泛型的类型参数没有边界时,它将被替换成 Object。

下面再看看类型参数有边界时的情况:

// 源码
public class Node<T extends Comparable<T>> {
    private T data;
    private Node<T> next;
    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }
    public T getData() {
        return data;
    }
}

上述源码中,泛型的类型参数有边界。所以经 Java编译器执行类型擦除后,类型参数将被替换成 第一个边界类,结果如下:

// Java编译器执行类型擦除后
public class Node {
    private Comparable data;
    private Node next;
    public Node(Comparable data, Node next) {
        this.data = data;
        this.next = next;
    }
    public Comparable getData() {
        return data;
    }
}

泛型方法的类型擦除

Java编译器还可以擦除泛型方法的类型参数。示例如下:

// 源码
public static <T> int count(T[] anArr, T ele) {
    int cnt = 0;
    for (T e : anArr) {
        if (e.equals(ele)) ++cnt;
    }
    return cnt;
}

上述源码中,类型参数 T 没有边界,所以经 Java编译器执行类型擦除后,它将被替换成 Object,结果如下:

// Java编译器执行类型擦除后
public static int count(Object[] anArr, Object ele) {
    int cnt = 0;
    for ( Object e : anArr ) {
        if ( e.equals(ele) ) ++cnt;
    }
    return cnt;
}

当泛型方法中类型参数有边界时,示例如下:

// 源码
public static <T extends Shape> void draw(T shape) { }
// Java编译器执行类型擦除后
public static void draw(Shape shape) { }

泛型类型擦除和桥接方法 的影响

泛型类型擦除,有时候会造成与预期不一致的情况。下面代码展示了这种情况发生的根本原因:

// 源码
class Node<T> {
    public T data;
    public Node(T data) { this.data = data; }
    public void setData(T data) { this.data = data; }
}
public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }
    public void setData(Integer data) { super.setData(data); }

    public static void main(String[] args) {
        MyNode mn = new MyNode(5);
        Node n = (MyNode)mn;    // 泛型的原始类型,编译会抛出警告
        n.setData("Hello");
        Integer x = (String)mn.data;    // 抛出 ClassCastException 异常
    }
}
// 执行类型擦除后
public class Node {
    public Object data;
    public Node(Object data) { this.data = data; }
    public void setData(Object data) { this.data = data; }
}
public class MyNode extends Node {
    public MyNode(Integer data) { super(data); }
    public void setData(Integer data) { super.setData(data); }
}

什么是桥接方法?

当编译一个继承自参数化类的类时,或者编译一个继承自参数化接口的接口时,Java编译器需要创建一个合成的方法用于类型擦除,这个方法就被称为“桥接方法”。一般来讲,你不必关心桥接方法,但是当发生了堆栈跟踪时桥接方法或许会让你感觉到一些困扰。

Non-Reifiable Types

Non-reifiable types are types where information has been removed at compile-time by type erasure — invocations of generic types that are not defined as unbounded wildcards. A non-reifiable type does not have all of its information available at runtime. Examples of non-reifiable types are List<String> and List<Number>; the JVM cannot tell the difference between these types at runtime.

泛型使用时的一些限制

为了高效地使用 Java泛型,你必须考虑到泛型的一些限制,如下:

  1. 不能实例化有原始类型的泛型类。
  2. 不能创建类型参数的实例。
  3. 不能声明有类型参数的静态成员。
  4. 不能在参数化类型中使用强制类型转换和 instanceof 运算符。
  5. 不能创建参数化类型的数组。
  6. 对参数化类型不能创建、捕获、抛出异常。
  7. 方法被java编译器执行类型擦除后,如果参数列表相同,这样的泛型方法不可以重载。

Java 包

本章将讲解如何对类、接口进行打包,如何使用包中的classes,如何组织源文件以使得Java编译器能够顺利执行编译。

创建 与 使用 Java包

为了让类和接口更容易地查找和使用,为了避免命名的冲突,为了控制程序的访问权限,编程人员通常会把相关的一组类和接口打包成一个 Java包。

一个包,即一组相关类型,提供了访问保护和命名空间的管理。这里的类型,包括类、接口、枚举和注释类型等。其中枚举和注释类型是一种特殊的类和接口。

Java平台中有很多包,它们是根据功能进行划分的。你可以创建你自己的 Java包。如下示例,我们试着来创建一个我们自己的 Java包:

// Draggable.java
package graphics;   // 包声明
public interface Draggable { }
// Graphic.java
package graphics;   // 包声明
public abstract class Graphic { }
// Circle.java
package graphics;   // 包声明
public class Circle extends Graphic implements Draggale { }
// Point.java
package graphics;   // 包声明
public class Point extends Graphic implements Draggable { }
// Line.java
package graphics;   // 包声明
public class Line extends Graphic implements Draggable { }

如上代码,我们封装了一组与图形绘制有关的程序,一个接口,一个抽象类,三个普通类。我们需要把这一组功能相关的类型进行打包,至于为什么需要打包,原因有以下几个:

  1. 你和其它程序员能够简单地判断这些类型之间是否相关联。
  2. 你和其它程序员能知道在哪里查找某个功能的类型。
  3. 你的类型的命名,不会与其它包中的类型命名发生冲突,因为包创建了新的命名空间。
  4. 你可以灵活地控制你的类型在当前包的内部不受访问限制,在当前包外受到访问限制。

如何创建 Java 包?

为了创建 Java包,你需要选择一个包名,并把包声明语句置于每一个类型源文件的首行。注意,如果在单个源文件中有多个类型时,你的文件名必须与 public 的那个类型名称一致。

在创建 Java包时,包声明必不可少,否则将得到一个未命名的包类型。

如何给 Java包选择一个包名?
关于包名命名的一些约定:

包名全部小写以避免与包中的类型名称发生冲突。公司应该使用自己公司的 domain 来给包命名,比如 com.example.mypackage 。Java官方的包,以 java. 和 javax. 开头。

如何使用 Java包中的包成员?

组成Java包的类型,被称为包成员。在包的外部使用 public 包成员,你可以通过包成员的命名来引用它,你可以导入这个包成员,你还可以导入这个包成员所在的包。

如何管理 Java源文件和类文件?

许多Java平台的实现,都依赖于“按层级划分的”文件系统,以此来管理源文件和类文件。尽管 Java语言规范并没有这样要求。

  1. 所有 Java源文件,都以 .java结束。
  2. 把 java源文件放进包中,且包名能够反映这些源文件的功能。
  3. 设置系统的 classpath 环境变量。

Java包 小结

  1. 为了能够打包,Java的类文件、接口文件、枚举文件、注释文件的首行代码必须是包声明。
  2. 跨 Java包使用 public 包成员,有三种方式可选择,分别是:使用包成员名称进行导入,导入这个包成员所在的包,或者直接在代码中使用这个包成员的完整名称。
  3. 包中成员的完整路径名称,映射着包路径。
  4. 为系统设置 classpath 环境变量,能够帮助 Java编译器和 JVM 找到 .class 文件。

本章完!!!
上一篇下一篇

猜你喜欢

热点阅读