《OnJava8》精读(七)文件、字符串及泛型

在这里插入图片描述

@

介绍


《On Java 8》是什么?

它是《Thinking In Java》的作者Bruce Eckel基于Java8写的新书。里面包含了对Java深入的理解及思想维度的理念。可以比作Java界的“武学秘籍”。任何Java语言的使用者,甚至是非Java使用者但是对面向对象思想有兴趣的程序员都该一读的经典书籍。目前豆瓣评分9.5,是公认的编程经典。

为什么要写这个系列的精读博文?

由于书籍读起来时间久,过程漫长,因此产生了写本精读系列的最初想法。除此之外,由于中文版是译版,读起来还是有较大的生硬感(这种差异并非译者的翻译问题,类似英文无法译出唐诗的原因),这导致我们理解作者意图需要一点推敲。再加上原书的内容很长,只第一章就多达一万多字(不含代码),读起来就需要大量时间。

所以,如果现在有一个人能替我们先仔细读一遍,筛选出其中的精华,让我们可以在地铁上或者路上不用花太多时间就可以了解这边经典书籍的思想那就最好不过了。于是这个系列诞生了。

一些建议

推荐读本书的英文版原著。此外,也可以参考本书的中文译版。我在写这个系列的时候,会尽量的保证以“陈述”的方式表达原著的内容,也会写出自己的部分观点,但是这种观点会保持理性并尽量少而精。本系列中对于原著的内容会以引用的方式体现。
最重要的一点,大家可以通过博客平台的评论功能多加交流,这也是学习的一个重要环节。

第十七章 文件


本章总字数:6000
关键词:

  • 文件和目录
  • 文件查找
  • 文件读写

本章的内容不多,作者主要提到了文件的部分操作。一开始作者疯狂吐槽了在Java7之前的IO模块,认为其“设计者毫不在意他们的使用者的体验这一观念”。

好在Java7之后,新的IO设计已经相当强大,特别是结合了Java8中的streams 使得文件操作更加尤雅。

文件和目录

Path用来操作路径相关的所有操作。

// files/PartsOfPaths.java
import java.nio.file.*;

public class PartsOfPaths {
    public static void main(String[] args) {
        System.out.println(System.getProperty("os.name"));
        Path p = Paths.get("PartsOfPaths.java").toAbsolutePath();
        for(int i = 0; i < p.getNameCount(); i++)
            System.out.println(p.getName(i));
        System.out.println("ends with '.java': " +
        p.endsWith(".java"));
        for(Path pp : p) {
            System.out.print(pp + ": ");
            System.out.print(p.startsWith(pp) + " : ");
            System.out.println(p.endsWith(pp));
        }
        System.out.println("Starts with " + p.getRoot() + " " + p.startsWith(p.getRoot()));
    }
}

结果:

Windows 10
Users
Bruce
Documents
GitHub
on-java
ExtractedExamples
files
PartsOfPaths.java
ends with '.java': false
Users: false : false
Bruce: false : false
Documents: false : false
GitHub: false : false
on-java: false : false
ExtractedExamples: false : false
files: false : false
PartsOfPaths.java: false : true
Starts with C:\ true

可以通过 getName() 来索引 Path 的各个部分,直到达到上限 getNameCount()。Path 也实现了 Iterable 接口,因此我们也可以通过增强的 for-each 进行遍历。请注意,即使路径以 .java 结尾,使用 endsWith() 方法也会返回 false。这是因为使用 endsWith() 比较的是整个路径部分,而不会包含文件路径的后缀。通过使用 startsWith() 和 endsWith() 也可以完成路径的遍历。但是我们可以看到,遍历 Path 对象并不包含根路径,只有使用 startsWith() 检测根路径时才会返回 true。

此外,Files 工具类有一系列获取Path信息的方法。

    static void say(String id, Object result) {
        System.out.print(id + ": ");
        System.out.println(result);
    }
    ...
        Path p = Paths.get("PathAnalysis.java").toAbsolutePath();
        say("Exists", Files.exists(p));
        say("Directory", Files.isDirectory(p));
        say("Executable", Files.isExecutable(p));
        say("Readable", Files.isReadable(p));
        say("RegularFile", Files.isRegularFile(p));
        say("Writable", Files.isWritable(p));
        say("notExists", Files.notExists(p));
        say("Hidden", Files.isHidden(p));
        say("size", Files.size(p));
        say("FileStore", Files.getFileStore(p));
        say("LastModified: ", Files.getLastModifiedTime(p));
        say("Owner", Files.getOwner(p));
        say("ContentType", Files.probeContentType(p));
        say("SymbolicLink", Files.isSymbolicLink(p));

结果:

Exists: true
Directory: false
Executable: true
Readable: true
RegularFile: true
Writable: true
notExists: false
Hidden: false
size: 1631
FileStore: SSD (C:)
LastModified: : 2017-05-09T12:07:00.428366Z
Owner: MINDVIEWTOSHIBA\Bruce (User)
ContentType: null
SymbolicLink: false

WatchService 通过进程监视目录。比如下一个示例中监视删除目录下.txt结尾的文件:

// files/PathWatcher.java
// {ExcludeFromGradle}
import java.io.IOException;
import java.nio.file.*;
import static java.nio.file.StandardWatchEventKinds.*;
import java.util.concurrent.*;

public class PathWatcher {
    static Path test = Paths.get("test");

    static void delTxtFiles() {
        try {
            Files.walk(test)
            .filter(f ->
                f.toString()
                .endsWith(".txt"))
                .forEach(f -> {
                try {
                    System.out.println("deleting " + f);
                    Files.delete(f);
                } catch(IOException e) {
                    throw new RuntimeException(e);
                }
            });
        } catch(IOException e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) throws Exception {
        Directories.refreshTestDir();
        Directories.populateTestDir();
        Files.createFile(test.resolve("Hello.txt"));
        WatchService watcher = FileSystems.getDefault().newWatchService();
        test.register(watcher, ENTRY_DELETE);
        Executors.newSingleThreadScheduledExecutor()
        .schedule(PathWatcher::delTxtFiles,
        250, TimeUnit.MILLISECONDS);
        WatchKey key = watcher.take();
        for(WatchEvent evt : key.pollEvents()) {
            System.out.println("evt.context(): " + evt.context() +
            "\nevt.count(): " + evt.count() +
            "\nevt.kind(): " + evt.kind());
            System.exit(0);
        }
    }
}

结果:

deleting test\bag\foo\bar\baz\File.txt
deleting test\bar\baz\bag\foo\File.txt
deleting test\baz\bag\foo\bar\File.txt
deleting test\foo\bar\baz\bag\File.txt
deleting test\Hello.txt
evt.context(): Hello.txt
evt.count(): 1
evt.kind(): ENTRY_DELETE

查看输出的具体内容。即使我们正在删除以 .txt 结尾的文件,在 Hello.txt 被删除之前,WatchService 也不会被触发。你可能认为,如果说"监视这个目录",自然会包含整个目录和下面子目录,但实际上:只会监视给定的目录,而不是下面的所有内容。如果需要监视整个树目录,必须在整个树的每个子目录上放置一个 Watchservice。

文件查找

上文中一直通过目录查找文件,实际上在java.nio.file中有更好的解决方案。

PathMatcher matcher = FileSystems.getDefault()
          .getPathMatcher("glob:**/*.{tmp,txt}");
PathMatcher matcher2 = FileSystems.getDefault()
          .getPathMatcher("glob:*.tmp");
Files.walk(test)
          .filter(matcher::matches)
          .forEach(System.out::println);
Files.walk(test)
          .filter(matcher2::matches)
          .forEach(System.out::println);          

通过在 FileSystem 对象上调用 getPathMatcher() 获得一个 PathMatcher,然后传入您感兴趣的模式。模式有两个选项:glob 和 regex。

在 matcher 中,glob 表达式开头的 / 表示“当前目录及所有子目录”,这在当你不仅仅要匹配当前目录下特定结尾的 Path 时非常有用。单 * 表示“任何东西**”,然后是一个点,然后大括号表示一系列的可能性——我们正在寻找以 .tmp 或 .txt 结尾的东西。您可以在 getPathMatcher() 文档中找到更多详细信息。matcher2 只使用 *.tmp ,通常不匹配任何内容,但是添加 map() 操作会将完整路径减少到末尾的名称。
注意,在这两种情况下,输出中都会出现 dir.tmp,即使它是一个目录而不是一个文件。要只查找文件,必须像在最后 files.walk() 中那样对其进行筛选。

读写文件

java.nio.file.Files 对于较小的文件可以轻松读写。Files.readAllLines() 一次读取整个文件。

// files/ListOfLines.java
import java.util.*;
import java.nio.file.*;

public class ListOfLines {
    public static void main(String[] args) throws Exception {
        Files.readAllLines(
        Paths.get("../streams/Cheese.dat"))
        .stream()
        .filter(line -> !line.startsWith("//"))
        .map(line ->
            line.substring(0, line.length()/2))
        .forEach(System.out::println);
    }
}

结果:

Not much of a cheese
Finest in the
And what leads you
Well, it's
It's certainly uncon

Files.write()用来写入:

// files/Writing.java
import java.util.*;
import java.nio.file.*;

public class Writing {
    static Random rand = new Random(47);
    static final int SIZE = 1000;

    public static void main(String[] args) throws Exception {
        // Write bytes to a file:
        byte[] bytes = new byte[SIZE];
        rand.nextBytes(bytes);
        Files.write(Paths.get("bytes.dat"), bytes);
        System.out.println("bytes.dat: " + Files.size(Paths.get("bytes.dat")));

        // Write an iterable to a file:
        List<String> lines = Files.readAllLines(
          Paths.get("../streams/Cheese.dat"));
        Files.write(Paths.get("Cheese.txt"), lines);
        System.out.println("Cheese.txt: " + Files.size(Paths.get("Cheese.txt")));
    }
}

结果:

bytes.dat: 1000
Cheese.txt: 199

现在,我们也可以使用流(stream)来读写:

//读取文件
FileInputStream Fis = new FileInputStream("C:\\jimmy.txt");
int i = Fis.read();
while(i!=-1) {
	i = Fis.read();
}
Ins.close();
//写入文件
FileOutputStream fos = new FileOutputStream("C:\\jimmy.txt");
String str = "hi~Jimmy";
byte[] bt = str.getBytes();
fos.write(bt);	
fos.close(); 

第十八章 字符串

本章总字数:16000

关键词:

  • 字符串是不可变的
  • +号的重载以及StringBuilder
  • 字符串操作
  • 格式化输出

本章又是一个基础但又重要的章节。原本我很疑惑为什么作者要把字符串这么基础的内容放到第十八章才讲(本书共二十五章节)。在后来读完了内容慢慢有一点体会:字符串虽然是一个基础类型,但同时又很特殊,它跟经常使用的int、Boolean、char等等都有很大区别。作为非数值类型,String又可以使用+号,这里又牵扯到符号重载的知识,以及String与数组之间的各种关系。更不用说常见的数值类型与字符串直接的相互转化。这些都需要有一定基础知识才能讲明白。

字符串是不可变的

字符串的值一旦创建就不会改变。或许会觉不可思议,若对Java(或者C#等面向对象语言)有底层了解的人会更容易理解。

String 对象是不可变的,你可以给一个 String 对象添加任意多的别名。因为 String 是只读的,所以指向它的任何引用都不可能修改它的值,因此,也就不会影响到其他引用。

初学者这时候可能会有点疑惑,比如这个例子中有几个字符串:

String a="a"+"b";
String b="ab";
String c=new String("ab");

System.out.println(a==b);
System.out.println(a.equals(b));
System.out.println(c==b);
System.out.println(c.equals(b));

答案是4个:“a”、“b”、“ab”,“ab”

结果:

true
true
false
true

这个示例中的==用来比较对象的引用是否是同一个内存地址,而equals() 用来比较值是否相同。很显然,对象c 由于使用了new 关键词而重新创建了一个“ab”的内容引用。

为了语言性能也为了方便使用,Java使用了字符串永不改变的概念(C#也一样)。字符串“ab”在创建之后已经存在于常量池中。之后的对象b在创建引用时,由于内存已经有了“ab”,Java会将堆的地址指针直接赋值给该对象,而不需要重新创建。除非我们显式的使用new关键词创建字符串对象。

再来看看原著中的示例:

String q = "howdy";
System.out.println(q); // howdy 
String qq = upcase(q); 
System.out.println(qq); // HOWDY 
System.out.println(q); // howdy 

当把 q 传递给 upcase() 方法时,实际传递的是引用的一个拷贝。其实,每当把 String 对象作为方法的参数时,都会复制一份引用,而该引用所指向的对象其实一直待在单一的物理位置上,从未动过。
回到 upcase() 的定义,传入其中的引用有了名字 s,只有 upcase() 运行的时候,局部引用 s 才存在。一旦 upcase() 运行结束,s 就消失了。当然了,upcase() 的返回值,其实是最终结果的引用。这足以说明,upcase() 返回的引用已经指向了一个新的对象,而 q 仍然在原来的位置。

所以,现在你应该能够理解“字符串是不可变的”含义了——不可变的是字符串对象指向的内存中的值。这些值一旦创建就会一直存在,并不意味字符串对象不可以改变。字符串 a也可以赋上新值,只不过此时赋值会创建一个新内存地址并赋予该对象引用。

字符串是非数值类型,但是可以使用+ 号,使用 +号表示将前后字符串的内容拼接。

如果你使用反编译工具(javap )来看拼接字符串的代码,你就会发现,Java在编译过程中自动使用了 StringBuilder 。使用它可以更加优化字符串的操作效率。

看原著的一个例子:

// strings/UsingStringBuilder.java 

import java.util.*; 
import java.util.stream.*; 
public class UsingStringBuilder { 
    public static String string1() { 
        Random rand = new Random(47);
        StringBuilder result = new StringBuilder("["); 
        for(int i = 0; i < 25; i++) { 
            result.append(rand.nextInt(100)); 
            result.append(", "); 
        } 
        result.delete(result.length()-2, result.length()); 
        result.append("]");
        return result.toString(); 
    } 
    public static String string2() { 
        String result = new Random(47)
            .ints(25, 0, 100)
            .mapToObj(Integer::toString)
            .collect(Collectors.joining(", "));
        return "[" + result + "]"; 
    } 
    public static void main(String[] args) { 
        System.out.println(string1()); 
        System.out.println(string2()); 
    }
} 

结果:

[58, 55, 93, 61, 61, 29, 68, 0, 22, 7, 88, 28, 51, 89, 
9, 78, 98, 61, 20, 58, 16, 40, 11, 22, 4] 
[58, 55, 93, 61, 61, 29, 68, 0, 22, 7, 88, 28, 51, 89,
9, 78, 98, 61, 20, 58, 16, 40, 11, 22, 4] 

StringBuilder 提供了丰富而全面的方法,包括 insert()、replace()、substring(),甚至还有reverse(),但是最常用的还是 append() 和 toString()。还有 delete(),上面的例子中我们用它删除最后一个逗号和空格,以便添加右括号。
string2() 使用了 Stream,这样代码更加简洁美观。可以证明,Collectors.joining() 内部也是使用的 > StringBuilder,这种写法不会影响性能!

字符串操作

原著介绍的过多,此处只筛选出常用的。

方法 作用 参数,重载版本
构造方法 创建String对象 默认版本,String,StringBuilder,StringBuffer,char数组,byte数组
length() String中字符的个数
toCharArray() 生成一个char[],包含String中的所有字符
equals(),equalsIgnoreCase() 比较两个String的内容是否相同。如果相同,结果为true 与之进行比较的String
compareTo(),compareToIgnoreCase() 按词典顺序比较String的内容,比较结果为负数、零或正数。注意,大小写不等价 与之进行比较的String
isEmpty() 返回boolean结果,以表明String对象的长度是否为0
indexOf(),lastIndexOf() 如果该String并不包含此参数,就返回-1;否则返回此参数在String中的起始索引。lastIndexOf()是从后往前搜索 重载版本包括:char,char与起始索引,String,String与起始索引
matches() 返回boolean结果,以表明该String和给出的正则表达式是否匹配 一个正则表达式
split() 按照正则表达式拆分String,返回一个结果数组 一个正则表达式。可选参数为需要拆分的最大数量
join()(Java8引入的) 用分隔符拼接字符片段,产生一个新的String 分隔符,待拼字符序列。用分隔符将字符序列拼接成一个新的String
substring()(即subSequence()) 返回一个新的String对象,以包含参数指定的子串 重载版本:起始索引;起始索引+终止索引
concat() 返回一个新的String对象,内容为原始String连接上参数String 要连接的String
replace() 返回替换字符后的新String对象。如果没有替换发生,则返回原始的String对象 要替换的字符,用来进行替换的新字符。也可以用一个CharSequence替换另一个CharSequence
toLowerCase(),toUpperCase() 将字符的大小写改变后,返回一个新的String对象。如果没有任何改变,则返回原始的String对象
trim() 将String两端的空白符删除后,返回一个新的String对象。如果没有任何改变,则返回原始的String对象
format() 要格式化的字符串,要替换到格式化字符串的参数 返回格式化结果String

在之后的篇幅中,作者讲解了正则表达式。但也只是讲了很基础的部分(主要因为正则表达式还是很难的,一两句讲不完),本篇的精读不深入正则表达式。如果要专门学习,可以找相应的专门的博客,会比原著讲的更细。

第十九章 类型信息

本章总字数:21000

关键词:

  • RTTI
  • 类型转换检测
  • 反射

本章是一个过渡章节,为下一章的泛型内容做铺垫。为了大家更好的理解泛型实现原理,在本章主要讲了RTTI(RunTime Type Information,运行时类型信息)及反射的概念。

什么是RTTI

我们先看一个原著的例子。
在这里插入图片描述

// typeinfo/Shapes.java
import java.util.stream.*;

abstract class Shape {
    void draw() { System.out.println(this + ".draw()"); }
    @Override
    public abstract String toString();
}

class Circle extends Shape {
    @Override
    public String toString() { return "Circle"; }
}

class Square extends Shape {
    @Override
    public String toString() { return "Square"; }
}

class Triangle extends Shape {
    @Override
    public String toString() { return "Triangle"; }
}

public class Shapes {
    public static void main(String[] args) {
        Stream.of(
            new Circle(), new Square(), new Triangle())
            .forEach(Shape::draw);
    }
}

结果:

Circle.draw()
Square.draw()
Triangle.draw()
  • 编译期,stream 和 Java 泛型系统确保放入 stream 的都是 Shape 对象(Shape 子类的对象也可视为 Shape的对象),否则编译器会报错;
  • 运行时,自动类型转换确保了从 stream 中取出的对象都是 Shape 类型。

Shape 对象实际执行什么样的代码,是由引用所指向的具体对象(Circle、Square 或者 Triangle)决定的。这也符合我们编写代码的一般需求,通常,我们希望大部分代码尽可能少了解对象的具体类型,而是只与对象家族中的一个通用表示打交道(本例中即为 Shape)。这样,代码会更容易写,更易读和维护;设计也更容易实现,更易于理解和修改。所以多态是面向对象的基本目标。

所以RTTI指的是在运行时,一个引用不仅仅指向和自己类型一致的对象,还可以指向派生类对象。“使用 RTTI,我们可以查询某个基类引用所指向对象的确切类型,然后选择或者剔除特例。

类型转换检测

在Java中,RTTI会在编译时确认对象的类型是否使用正确。如果不正确会抛出ClassCastException 异常。

RTTI在Java中还有一个使用方式: instanceof——告诉我们对象是不是某个特定类型的实例。

例如:

if(x instanceof Dog)
    ((Dog)x).bark();

在将 x 的类型转换为 Dog 之前,if 语句会先检查 x 是否是 Dog 类型的对象。进行向下转型前,如果没有其他信息可以告诉你这个对象是什么类型,那么使用 instanceof 是非常重要的,否则会得到一个 ClassCastException 异常。

反射

RTTI的存在使得我们在使用类型时更加的安全。但是也存在另外一个缺陷。如果在编译时并不知道某一个类型,RTTI就无法检测它。

一个示例:

// typeinfo/ShowMethods.java
// 使用反射展示一个类的所有方法,甚至包括定义在基类中方法
// {java ShowMethods ShowMethods}
import java.lang.reflect.*;
import java.util.regex.*;

public class ShowMethods {
    private static String usage =
            "usage:\n" +
            "ShowMethods qualified.class.name\n" +
            "To show all methods in class or:\n" +
            "ShowMethods qualified.class.name word\n" +
            "To search for methods involving 'word'";
    private static Pattern p = Pattern.compile("\\w+\\.");

    public static void main(String[] args) {
        if (args.length < 1) {
            System.out.println(usage);
            System.exit(0);
        }
        int lines = 0;
        try {
            Class<?> c = Class.forName(args[0]);
            Method[] methods = c.getMethods();
            Constructor[] ctors = c.getConstructors();
            if (args.length == 1) {
                for (Method method : methods)
                    System.out.println(
                            p.matcher(
                                    method.toString()).replaceAll(""));
                for (Constructor ctor : ctors)
                    System.out.println(
                            p.matcher(ctor.toString()).replaceAll(""));
                lines = methods.length + ctors.length;
            } else {
                for (Method method : methods)
                    if (method.toString().contains(args[1])) {
                        System.out.println(p.matcher(
                                method.toString()).replaceAll(""));
                        lines++;
                    }
                for (Constructor ctor : ctors)
                    if (ctor.toString().contains(args[1])) {
                        System.out.println(p.matcher(
                                ctor.toString()).replaceAll(""));
                        lines++;
                    }
            }
        } catch (ClassNotFoundException e) {
            System.out.println("No such class: " + e);
        }
    }
}

结果:

public static void main(String[])
public final void wait() throws InterruptedException
public final void wait(long,int) throws
InterruptedException
public final native void wait(long) throws
InterruptedException
public boolean equals(Object)
public String toString()
public native int hashCode()
public final native Class getClass()
public final native void notify()
public final native void notifyAll()
public ShowMethods()

Class 方法 getmethods() 和 getconstructors() 分别返回 Method 数组和 Constructor 数组。这些类中的每一个都有进一步的方法来解析它们所表示的方法的名称、参数和返回值。但你也可以像这里所做的那样,使用 toString(),生成带有整个方法签名的 String。代码的其余部分提取命令行信息,确定特定签名是否与目标 String(使用 indexOf())匹配,并使用正则表达式(在 Strings 一章中介绍)删除名称限定符。

至此,我们了解了两种识别对象类型的方式:

  • “传统的” RTTI:假定我们在编译时已经知道了所有的类型;
  • “反射”机制:允许我们在运行时发现和使用类的信息。

了解这些对我们后续学习泛型至关重要。

第二十章 泛型

本章总字数:40000

关键词:

  • 泛型接口
  • 泛型方法
  • 泛型擦除
  • 通配符
  • 自限定

Java的泛型概念灵感来源于C++的模版类。Java5引入泛型,起初的动机之一是为了集合(可以参考前面集合章节)。集合比数组更灵活,而且又可以使用任何类型,这其中就有泛型的功劳。

一个简单的泛型

当一个类要定义成泛型类,需要使用类型参数,下方示例的T 就是类型参数。

public class GenericHolder<T> {
    private T a;
    public GenericHolder() {}
    public void set(T a) { this.a = a; }
    public T get() { return a; }
    
	class Automobile {}
	
    public static void main(String[] args) {
        GenericHolder<Automobile> h3 = new GenericHolder<Automobile>();
        h3.set(new Automobile()); // 此处有类型校验
        Automobile a = h3.get();  // 无需类型转换
        //- h3.set("Not an Automobile"); // 报错
        //- h3.set(1);  // 报错
    }
}

需要说明的是,在Java7以后泛型的初始化可以简写:

//GenericHolder<Automobile> h3 = new GenericHolder<Automobile>();
GenericHolder<Automobile> h3 = new GenericHolder<>();
h3.set(new Automobile());

泛型接口

泛型接口是一个含有类型参数的interface,在接口内部也可以使用该类型参数。

public interface Sum<T> {
    public T getResult(T a,T b);
}

public class IntegerSum  implements Sum<Integer>
{
    @Override
    public Integer getResult(Integer a, Integer b) {
        return a+b;
    }
}
public class LongSum  implements Sum<Long>
{
    @Override
    public Long getResult(Long a, Long b) {
        return a+b;
    }
}

注意:Java中不允许多个类写在同一个.java文件中,此处为了方便理解仅做示例。

示例中两个类都实现了Sum接口,但是支持的数据类型却不一样。而且支持类型也作用到了传入的参数及返回值。

泛型方法

泛型接口是针对类级别的“泛化”,有时候我们需要局部的“泛化”——比如方法级别的泛型。

直接看原著示例:

// generics/GenericMethods.java

public class GenericMethods {
    public <T> void f(T x) {
        System.out.println(x.getClass().getName());
    }

    public static void main(String[] args) {
        GenericMethods gm = new GenericMethods();
        gm.f("");
        gm.f(1);
        gm.f(1.0);
        gm.f(1.0F);
        gm.f('c');
        gm.f(gm);
    }
}

结果:

java.lang.String
java.lang.Integer
java.lang.Double
java.lang.Float
java.lang.Character
GenericMethods

在Java中我们可以使用 ...表示一批没有数量限制的参数——变长参数。如下的示例中参数 num可以传入多个也可以不传。

 public void print(Integer... num){
 ...

使用了泛型后,依然可以这么做:

public <T> List<T> makeList(T... args) {
...

泛型擦除

这个词看起来有些生僻,我们可以通过一个示例先了解:

    public static void main(String[] args) {
        Class c1 = new ArrayList<String>().getClass();
        Class c2 = new ArrayList<Integer>().getClass();
        System.out.println(c1 == c2);
    }

结果:

true

这个结果可能让你有些意外,虽然两个ArrayList泛型参数完全不同,但是Java程序认为他们的类型一样。

究其原因,与泛型的实现方式有关。和C++的模板类的实现不同,Java的泛型使用了泛型擦除的方式。在使用泛型时,具体的类型被擦除,这个示例中两个ArrayList的类型都是擦除成了原始的List类型,所以才会出现类型相同的情况。

Java 泛型是使用擦除实现的。这意味着当你在使用泛型时,任何具体的类型信息都被擦除了,你唯一知道的就是你在使用一个对象。

为什么Java要使用这种泛型擦除的方式实现泛型?毕竟这种方式导致了不少疑惑,比如上个示例中展现的类型判断就是其中之一。作者在原著中用一些篇幅做了解释,说到底与Java的向后兼容有关。

我们知道泛型是Java5才被引入的,在Java5之前的时代,Java社区中已经出现了大量的类库,这些类库被Java开发者广泛使用。如果一个新特性的引入需要旧类库被迫做出改变是不现实的,这对Java社区也是一个重大打击——Java社区的活跃是Java之所以发展壮大的主要原因。因此,为了妥协也或许是唯一的办法,就是使用一种可以兼容旧版本Java又可以实现新特性的技术,这就是泛型擦除。

当某个类库变为泛型时,不会破坏依赖于它的代码和应用。在确定了这个目标后,Java 设计者们和从事此问题相关工作的各个团队决策认为擦除是唯一可行的解决方案。擦除使得这种向泛型的迁移成为可能,允许非泛型的代码和泛型代码共存。

在了解了泛型擦除之后,我们在使用泛型时要时刻注意。

比如:

class Foo<T> {
    T var;
}
public class cat {}
...
Foo<Cat> f = new Foo<>();

当你声明一个 Foo< Cat > 时,一定要明白,你只是声明了一个Object:

Foo<Object> f = new Foo<>();

擦除引出的类型判断问题,可以使用一些方法来解决,其中之一是使用isInstance。

public class ClassTypeCapture<T> {
    Class<T> kind;

    public ClassTypeCapture(Class<T> kind) {
        this.kind = kind;
    }

    public boolean f(Object arg) {
        return kind.isInstance(arg);
    }
}

由于擦除删除了类型信息,如果在泛型类型中需要使用限定性的动作,就必须有类型的边界限制。通俗的讲,有一个动物的泛型类,需要传入的T都是具有“吃”和“睡”的行为,如果我们对T不加限制,那在调用这些行为时就有不可预知的bug。

在Java泛型中我们可以使用extends 关键词来限制边界。

class Bounded extends Coord implements HasColo{
...
}
class WithColorCoord<T extends Coord & HasColor> {
...
}

通配符

在本章作者用了大篇幅的范例,但是内容不那么容易理解。所以这里我重新举例来讲讲。

extends super 关键词可以限制泛型类型的边界。

看一个例子:

	public class Animal {}
    public class Cat extends Animal {}
    public class BlackCat extends Cat {}

    public class House<T> {
        private T item;
        public House(T t) {
            item = t;
        }
        public void set(T t) {
            item = t;
        }
        public T get() {
            return item;
        }
    }

我们看到了Cat继承自Animal,而BlackCat继承自Cat类。我们在使用泛型类House时,出现了一个问题:

Cat c1=new BlackCat();//OK
House<Cat> h2=new House<BlackCat>(new BlackCat());//Error

很显然,虽然黑猫也是猫,但是猫住的窝,黑猫却不能住。这让人感到疑惑。很显然编译器没有我们想象的那么聪明,它遇到了类型判断上的困难,在不确定类型关系的前提下给了错误提示。为了解决这个问题,Java设计者提出了通配符来控制类型上下界关系。

  • :上界通配符(Upper Bounds Wildcards)
  • :下界通配符(Lower Bounds Wildcards)

上界代表:凡是指定类型或者指定类型的派生类
下界代表:凡是指定类型或者指定类型的任何基类

这样我们就可以严格控制泛型类型的边界,并且告诉编译器我们的声明类型之间的联系。

House<? extends Cat> h2=new House<BlackCat>(new BlackCat());//OK
House<? super Cat> h3=new House<Animal>(new BlackCat());//OK

翻译一下:
凡是Cat的派生类或者Cat本身可以被声明。因为BlackCat是Cat的派生类,所以可以声明。
凡是Cat的基类或者Cat本身可以被声明。因为Animal是Cat的基类,所以可以被声明。

让我们使用通配符来声明,并且尝试为其赋值:

House<? extends Cat> house = new House<BlackCat>(new BlackCat());
house.set(new Animal());//Error
house.set(new Cat());//Error
house.set(new BlackCat());//Error
Animal animal1 = house.get();
Cat cat1 = house.get();
BlackCat blackCat1 = house.get();//Error
Object animal2 = house.get();

House<? super Cat> house2 = new House<Animal>(new Cat());
house2.set(new Animal());//Error
house2.set(new Cat());
house2.set(new BlackCat());
Animal animal3 = house2.get();//Error
Cat cat2 = house2.get();//Error
Object animal4 = house2.get();

结果与我们预期有很大差异。这其中有不少代码被编译器认为是错误的。这就涉及到了上下界的局限性。

因为 set() 的参数也是"? extends Fruit",意味着它可以是任何事物,编译器无法验证“任何事物”的类型安全性.

在为上界赋值时,由于编译器无法确定其参数类型到底是指定类的哪一种派生类,出于类型安全性会给予错误提示。但是获取值时,编译器可以确定其返回类型(返回值一定是一只猫且一定是一只动物)。

相同的道理,在下界中get()方法的返回值由于编译器无法确定其类型到底是那一种,所以只能接受所有类的基类——Object类型。

自限定

自限定是个有趣的概念。在定义一个泛型类时我们对泛型参数的限定可以作用于这个类本身。

class SelfBounded<T extends SelfBounded<T>> { // ...

这个语句的意思是,这个类中的泛型参数T 必须是继承自这个类(也就是它的派生类)。

自限定在某些情景下会有奇效,比如用来限制方法参数只能用特定类型:

class SelfBounded<T extends SelfBounded<T>> {
    T element;
    SelfBounded<T> set(T arg) {
        element = arg;
        return this;
    }
    T get() { return element; }
}
...
static <T extends SelfBounded<T>> T f(T arg) {
	return arg.set(arg).get();
}

总结

本篇的主要篇幅在泛型部分,特别是泛型擦除。原著中关于泛型的讲解着墨更多。如果对泛型的知识想进一步了解,建议读一遍原文章节。本篇的字符串部分属于基础但是很重要的部分,需要细细了解,因为经常会用到。

posted @ 2021-01-23 21:13  Hi-Jimmy  阅读(300)  评论(0编辑  收藏  举报