通过FileFilter
过滤
FileFilter
是另一个可以用来过滤文件和文件夹的函数式接口。例如,让我们只过滤文件夹:
File[] folders = path.toFile().listFiles(new FileFilter() { @Override public boolean accept(File file) { return file.isDirectory(); } });
我们可以在函数式风格上做同样的事情:
File[] folders = path.toFile().listFiles((File file) -> file.isDirectory());
让我们更简洁一点:
File[] folders = path.toFile().listFiles(f -> f.isDirectory());
最后,我们可以通过成员引用:
File[] folders = path.toFile().listFiles(File::isDirectory);
145 循环字节缓冲区
JavaNIO.2API 附带了一个名为java.nio.ByteBuffer的字节缓冲区的实现。基本上,这是一个字节数组(byte[]),由一组专门用于操作该数组的方法包装(例如,get()、put()等等)。循环缓冲区(循环缓冲区、环形缓冲区或循环队列)是端到端连接的固定大小的缓冲区。下图显示了循环队列的外观:
循环缓冲区依赖于预先分配的数组(预先分配的容量),但某些实现可能也需要调整大小的功能。元素写入/添加到后面(尾部),从前面删除/读取(头部),如下图所示:
对于主操作,即读(获取)和写(设置),循环缓冲区维护一个指针(读指针和写指针)。两个指针都围绕着缓冲区容量。我们可以找出有多少元素可以读取,有多少空闲的插槽可以随时写入。此操作发生在O(1)。
循环字节缓冲区是字节的循环缓冲区;它可以是字符或其他类型。这正是我们要在这里实现的。我们可以从编写实现的存根开始,如下所示:
public class CircularByteBuffer { private int capacity; private byte[] buffer; private int readPointer; private int writePointer; private int available; CircularByteBuffer(int capacity) { this.capacity = capacity; buffer = new byte[capacity]; } public synchronized int available() { return available; } public synchronized int capacity() { return capacity; } public synchronized int slots() { return capacity - available; } public synchronized void clear() { readPointer = 0; writePointer = 0; available = 0; } ... }
现在,让我们集中精力放置(写入)新字节和读取(获取)现有字节。例如,容量为 8 的循环字节缓冲区可以表示为:
让我们看看每一步都发生了什么:
循环字节缓冲区为空,两个指针都指向插槽 0(第一个插槽)。
我们将hello对应的 5 个字节放在缓冲区中,readPointer保持不变,而writePointer指向插槽 5。
我们得到与h对应的字节,所以readPointer移动到插槽 1。
最后,我们尝试将world的字节放入缓冲区。这个字由 5 个字节组成,但在达到缓冲区容量之前,我们只有 4 个空闲插槽。这意味着我们只能写与world对应的字节。
现在,让我们看一下下图中的场景:
从左到右,步骤如下:
前两个步骤与前一个场景中的步骤相同。
我们得到了hell的字节。这将把readPointer移动到位置 4。
最后,我们将world的字节放入缓冲区。这一次,字放入缓冲区,writePointer移动到槽 2。
基于此流程,我们可以轻松实现一种方法,将一个字节放入缓冲区,另一个从缓冲区获取一个字节,如下所示:
public synchronized boolean put(int value) { if (available == capacity) { return false; } buffer[writePointer] = (byte) value; writePointer = (writePointer + 1) % capacity; available++; return true; } public synchronized int get() { if (available == 0) { return -1; } byte value = buffer[readPointer]; readPointer = (readPointer + 1) % capacity; available--; return value; }
如果我们检查 JavaNIO.2ByteBufferAPI,我们会注意到它公开了get()和put()方法的几种风格。例如,我们应该能够将一个byte[]传递给get()方法,这个方法应该将一系列元素从缓冲区复制到这个byte[]中。从当前的readPointer开始从缓冲区读取元素,从指定的offset开始在给定的byte[]中写入元素。
下图显示了writePointer大于readPointer的情况:
在左边,我们正在读 3 个字节。这将readPointer从其初始插槽 1 移动到插槽 4。在右边,我们正在读取 4 个(或超过 4 个)字节。由于只有 4 个字节可用,readPointer从其初始插槽移动到与writePointer相同的插槽(插槽 5)。
现在,我们来分析一个例子,writePointer小于readPointer:
在左边,我们正在读 3 个字节。这将readPointer从其初始插槽 6 移动到插槽 1。在右边,我们正在读取 4 个(或超过 4 个)字节。这会将readPointer从其初始插槽 6 移动到插槽 2(与writePointer相同的插槽)。
既然我们已经考虑到了这两个用例,我们可以编写一个get()方法,以便将一系列字节从缓冲区复制到给定的byte[],如下所示(该方法尝试从缓冲区读取len字节,然后将它们写入给定的byte[],从给定的offset开始):
public synchronized int get(byte[] dest, int offset, int len) { if (available == 0) { return 0; } int maxPointer = capacity; if (readPointer < writePointer) { maxPointer = writePointer; } int countBytes = Math.min(maxPointer - readPointer, len); System.arraycopy(buffer, readPointer, dest, offset, countBytes); readPointer = readPointer + countBytes; if (readPointer == capacity) { int remainingBytes = Math.min(len - countBytes, writePointer); if (remainingBytes > 0) { System.arraycopy(buffer, 0, dest, offset + countBytes, remainingBytes); readPointer = remainingBytes; countBytes = countBytes + remainingBytes; } else { readPointer = 0; } } available = available - countBytes; return countBytes; }
现在,让我们集中精力将给定的byte[]放入缓冲区。从指定的offset开始从给定的byte[]读取元素,并从当前的writePointer开始写入缓冲区。下图显示了writePointer大于readPointer的情况:
在左边,我们有缓冲区的初始状态。所以,readPointer指向插槽 2,writePointer指向插槽 5。在写入 4 个字节(右侧)之后,我们可以看到,readPointer没有受到影响,writePointer指向插槽 1。
另一个用例假设readPointer大于writePointer:
在左边,我们有缓冲区的初始状态。所以,readPointer指向插槽 4,writePointer指向插槽 2。在写入 4 个字节(右侧)之后,我们可以看到,readPointer没有受到影响,writePointer指向插槽 4。请注意,只有两个字节被成功写入。这是因为我们在写入所有 4 个字节之前已经达到了缓冲区的最大容量。
既然我们已经考虑到了这两个用例,我们可以编写一个put()方法,以便将给定的byte[]中的一系列字节复制到缓冲区中,如下(该方法尝试从给定的offset开始从给定的byte[]读取len字节,并尝试从当前的writePointer开始将其写入缓冲区):
public synchronized int put(byte[] source, int offset, int len) { if (available == capacity) { return 0; } int maxPointer = capacity; if (writePointer < readPointer) { maxPointer = readPointer; } int countBytes = Math.min(maxPointer - writePointer, len); System.arraycopy(source, offset, buffer, writePointer, countBytes); writePointer = writePointer + countBytes; if (writePointer == capacity) { int remainingBytes = Math.min(len - countBytes, readPointer); if (remainingBytes > 0) { System.arraycopy(source, offset + countBytes, buffer, 0, remainingBytes); writePointer = remainingBytes; countBytes = countBytes + remainingBytes; } else { writePointer = 0; } } available = available + countBytes; return countBytes; }
如前所述,有时需要调整缓冲区的大小。例如,我们可能希望通过简单地调用resize()
方法将其大小增加一倍。基本上,这意味着将所有可用字节(元素)复制到一个容量加倍的新缓冲区中:
public synchronized void resize() { byte[] newBuffer = new byte[capacity * 2]; if (readPointer < writePointer) { System.arraycopy(buffer, readPointer, newBuffer, 0, available); } else { int bytesToCopy = capacity - readPointer; System.arraycopy(buffer, readPointer, newBuffer, 0, bytesToCopy); System.arraycopy(buffer, 0, newBuffer, bytesToCopy, writePointer); } buffer = newBuffer; capacity = buffer.length; readPointer = 0; writePointer = available; }
查看本书附带的源代码,看看它是如何完整工作的。
146 分词文件
文件中的内容并不总是以可以立即处理的方式接收,并且需要一些额外的步骤,以便为处理做好准备。通常,我们需要对文件进行标记,并从不同的数据结构(数组、列表、映射等)中提取信息。
例如,让我们考虑一个文件,clothes.txt
:
Path path = Paths.get("clothes.txt");
其内容如下:
Top|white\10/XXL&Swimsuit|black\5/L Coat|red\11/M&Golden Jacket|yellow\12/XLDenim|Blue\22/M
此文件包含一些服装物品及其详细信息,这些物品以&
字符分隔。单一条款如下:
article name | color \ no. available items / size
这里,我们有几个分隔符(&、|、\、/)和一个非常具体的格式。
现在,让我们来看看几个解决方案,它们将从这个文件中提取信息并将其标记为一个List。我们将在工具类FileTokenizer中收集这些信息。
在List中获取物品的一种解决方案依赖于String.split()方法。基本上,我们必须逐行读取文件并对每行应用String.split()。每行分词的结果通过List.addAll()方法收集在List中:
public static List<String> get(Path path, Charset cs, String delimiter) throws IOException { String delimiterStr = Pattern.quote(delimiter); List<String> content = new ArrayList<>(); try (BufferedReader br = Files.newBufferedReader(path, cs)) { String line; while ((line = br.readLine()) != null) { String[] values = line.split(delimiterStr); content.addAll(Arrays.asList(values)); } } return content; }
使用&
分隔符调用此方法将产生以下输出:
[Top|white\10/XXL, Swimsuit|black\5/L, Coat|red\11/M, Golden Jacket|yellow\12/XL, Denim|Blue\22/M]
上述溶液的另一种风味可以依赖于Collectors.toList()
而不是Arrays.asList()
:
public static List<String> get(Path path, Charset cs, String delimiter) throws IOException { String delimiterStr = Pattern.quote(delimiter); List<String> content = new ArrayList<>(); try (BufferedReader br = Files.newBufferedReader(path, cs)) { String line; while ((line = br.readLine()) != null) { content.addAll(Stream.of(line.split(delimiterStr)) .collect(Collectors.toList())); } } return content; }
或者,我们可以通过Files.lines()
以惰性方式处理内容:
public static List<String> get(Path path, Charset cs, String delimiter) throws IOException { try (Stream<String> lines = Files.lines(path, cs)) { return lines.map(l -> l.split(Pattern.quote(delimiter))) .flatMap(Arrays::stream) .collect(Collectors.toList()); } }
对于相对较小的文件,我们可以将其加载到内存中并进行相应的处理:
Files.readAllLines(path, cs).stream() .map(l -> l.split(Pattern.quote(delimiter))) .flatMap(Arrays::stream) .collect(Collectors.toList());
另一种解决方案可以依赖于 JDK8 的Pattern.splitAsStream()
方法。此方法从给定的输入序列创建流。为了便于修改,这次我们通过Collectors.joining(";")
收集结果列表:
public static List<String> get(Path path, Charset cs, String delimiter) throws IOException { Pattern pattern = Pattern.compile(Pattern.quote(delimiter)); List<String> content = new ArrayList<>(); try (BufferedReader br = Files.newBufferedReader(path, cs)) { String line; while ((line = br.readLine()) != null) { content.add(pattern.splitAsStream(line) .collect(Collectors.joining(";"))); } } return content; }
我们用&
分隔符调用这个方法:
List<String> tokens = FileTokenizer.get( path, StandardCharsets.UTF_8, "&");
结果如下:
[Top|white\10/XXL;Swimsuit|black\5/L, Coat|red\11/M;Golden Jacket|yellow\12/XL, Denim|Blue\22/M]
到目前为止,提出的解决方案通过应用一个分隔符来获得文章列表。但有时,我们需要应用更多的分隔符。例如,假设我们希望获得以下输出(列表):
[Top, white, 10, XXL, Swimsuit, black, 5, L, Coat, red, 11, M, Golden Jacket, yellow, 12, XL, Denim, Blue, 22, M]
为了获得这个列表,我们必须应用几个分隔符(&
、|
、\
和/
。这可以通过使用String.split()
并将基于逻辑OR
运算符(x|y
)的正则表达式传递给它来实现:
public static List<String> getWithMultipleDelimiters( Path path, Charset cs, String...delimiters) throws IOException { String[] escapedDelimiters = new String[delimiters.length]; Arrays.setAll(escapedDelimiters, t -> Pattern.quote(delimiters[t])); String delimiterStr = String.join("|", escapedDelimiters); List<String> content = new ArrayList<>(); try (BufferedReader br = Files.newBufferedReader(path, cs)) { String line; while ((line = br.readLine()) != null) { String[] values = line.split(delimiterStr); content.addAll(Arrays.asList(values)); } } return content; }
让我们用分隔符(&、|、\和/调用此方法以获得所需的结果:
List<String> tokens = FileTokenizer.getWithMultipleDelimiters( path, StandardCharsets.UTF_8, new String[] {"&", "|", "\\", "/"});
好的,到目前为止,很好!所有这些解决方案都基于String.split()和Pattern.splitAsStream()。另一组解决方案可以依赖于StringTokenizer类(它在性能方面并不出色,所以请小心使用它)。此类可以对给定的字符串应用一个(或多个)分隔符,并公开控制它的两个主要方法,即hasMoreElements()和nextToken():
public static List<String> get(Path path, Charset cs, String delimiter) throws IOException { StringTokenizer st; List<String> content = new ArrayList<>(); try (BufferedReader br = Files.newBufferedReader(path, cs)) { String line; while ((line = br.readLine()) != null) { st = new StringTokenizer(line, delimiter); while (st.hasMoreElements()) { content.add(st.nextToken()); } } } return content; }
也可与Collectors
配合使用:
public static List<String> get(Path path, Charset cs, String delimiter) throws IOException { List<String> content = new ArrayList<>(); try (BufferedReader br = Files.newBufferedReader(path, cs)) { String line; while ((line = br.readLine()) != null) { content.addAll(Collections.list( new StringTokenizer(line, delimiter)).stream() .map(t -> (String) t) .collect(Collectors.toList())); } } return content; }
如果我们使用//
分隔多个分隔符,则可以使用多个分隔符:
public static List<String> getWithMultipleDelimiters( Path path, Charset cs, String...delimiters) throws IOException { String delimiterStr = String.join("//", delimiters); StringTokenizer st; List<String> content = new ArrayList<>(); try (BufferedReader br = Files.newBufferedReader(path, cs)) { String line; while ((line = br.readLine()) != null) { st = new StringTokenizer(line, delimiterStr); while (st.hasMoreElements()) { content.add(st.nextToken()); } } } return content; }
为了获得更好的性能和正则表达式支持(即高灵活性),建议使用String.split()
而不是StringTokenizer
。从同一类别中,也考虑“使用扫描器”部分。
147 将格式化输出直接写入文件
假设我们有 10 个数字(整数和双精度)并且我们希望它们在一个文件中被很好地格式化(有缩进、对齐和一些小数,以保持可读性和有用性)。
在我们的第一次尝试中,我们像这样将它们写入文件(没有应用格式):
Path path = Paths.get("noformatter.txt"); try (BufferedWriter bw = Files.newBufferedWriter(path, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { for (int i = 0; i < 10; i++) { bw.write("| " + intValues[i] + " | " + doubleValues[i] + " | "); bw.newLine(); } }
前面代码的输出类似于下图左侧所示:
但是,我们希望得到上图右侧所示的结果。为了解决这个问题,我们需要使用String.format()方法。此方法允许我们将格式规则指定为符合以下模式的字符串:
%[flags][width][.precision]conversion-character
现在,让我们看看这个模式的每个组成部分是什么:
[flags]是可选的,包括修改输出的标准方法。通常,它们用于格式化整数和浮点数。
[width]是可选的,并设置输出的字段宽度(写入输出的最小字符数)。
[.precision]可选,指定浮点值的精度位数(或从String中提取的子串长度)。
conversion-character是强制的,它告诉我们参数的格式。最常用的转换字符如下:
s:用于格式化字符串
d:用于格式化十进制整数
f:用于格式化浮点数
t:用于格式化日期/时间值
作为行分隔符,我们可以使用%n。
有了这些格式化规则的知识,我们可以得到如下所示(%6s用于整数,%.3f用于双精度):
Path path = Paths.get("withformatter.txt"); try (BufferedWriter bw = Files.newBufferedWriter(path, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { for (int i = 0; i<10; i++) { bw.write(String.format("| %6s | %.3f |", intValues[i], doubleValues[i])); bw.newLine(); } }
可以通过Formatter
类提供另一种解决方案。此类专用于格式化字符串,并使用与String.format()
相同的格式化规则。它有一个format()
方法,我们可以用它覆盖前面的代码片段:
Path path = Paths.get("withformatter.txt"); try (Formatter output = new Formatter(path.toFile())) { for (int i = 0; i < 10; i++) { output.format("| %6s | %.3f |%n", intValues[i], doubleValues[i]); } }
只格式化整数的数字怎么样?
好吧,我们可以通过应用一个DecimalFormat和一个字符串格式化程序来获得它,如下所示:
Path path = Paths.get("withformatter.txt"); DecimalFormat formatter = new DecimalFormat("###,### bytes"); try (Formatter output = new Formatter(path.toFile())) { for (int i = 0; i < 10; i++) { output.format("%12s%n", formatter.format(intValues[i])); } }
148 使用扫描器
Scanner公开了一个 API,用于解析字符串、文件、控制台等中的文本。解析是将给定的输入分词并根据需要返回它的过程(例如,整数、浮点、双精度等)。默认情况下,Scanner使用空格(默认分隔符)解析给定的输入,并通过一组nextFoo()方法(例如,next()、nextLine()、nextInt()、nextDouble()等)公开令牌。
从同一类问题出发,也考虑“分词文件”部分。
例如,假设我们有一个文件(doubles.txt),其中包含由空格分隔的双数,如下图所示:
如果我们想获得这个文本作为双精度文本,那么我们可以读取它并依赖于一段意大利面代码来标记并将其转换为双精度文本。或者,我们可以依赖于Scanner及其nextDouble()方法,如下所示:
try (Scanner scanDoubles = new Scanner( Path.of("doubles.txt"), StandardCharsets.UTF_8)) { while (scanDoubles.hasNextDouble()) { double number = scanDoubles.nextDouble(); System.out.println(number); } }
上述代码的输出如下:
23.4556 1.23 ...
但是,文件可能包含不同类型的混合信息。例如,下图中的文件(people.txt)包含由不同分隔符(逗号和分号)分隔的字符串和整数:
Scanner公开了一个名为useDelimiter()的方法。此方法采用String或Pattern类型的参数,以指定应用作正则表达式的分隔符:
try (Scanner scanPeople = new Scanner(Path.of("people.txt"), StandardCharsets.UTF_8).useDelimiter(";|,")) { while (scanPeople.hasNextLine()) { System.out.println("Name: " + scanPeople.next().trim()); System.out.println("Surname: " + scanPeople.next()); System.out.println("Age: " + scanPeople.nextInt()); System.out.println("City: " + scanPeople.next()); } }
使用此方法的输出如下:
Name: Matt Surname: Kyle Age: 23 City: San Francisco ...
从 JDK9 开始,Scanner
公开了一个名为tokens()
的新方法。此方法返回来自Scanner
的分隔符分隔的令牌流。例如,我们可以用它来解析people.txt
文件并在控制台上打印出来,如下所示:
try (Scanner scanPeople = new Scanner(Path.of("people.txt"), StandardCharsets.UTF_8).useDelimiter(";|,")) { scanPeople.tokens().forEach(t -> System.out.println(t.trim())); }
使用上述方法的输出如下:
Matt Kyle 23 San Francisco ...
或者,我们可以通过空格连接令牌:
try (Scanner scanPeople = new Scanner(Path.of("people.txt"), StandardCharsets.UTF_8).useDelimiter(";|,")) { String result = scanPeople.tokens() .map(t -> t.trim()) .collect(Collectors.joining(" ")); }
在“大文件搜索”部分中,有一个示例说明如何使用此方法搜索文件中的某一段文本。
使用上述方法的输出如下:
Matt Kyle 23 San Francisco Darel Der 50 New York ...
在tokens()
方法方面,JDK9 还附带了一个名为findAll()
的方法。这是一种非常方便的方法,用于查找所有与某个正则表达式相关的标记(以String
或Pattern
形式提供)。此方法返回一个Stream<MatchResult>
,可以这样使用:
try (Scanner sc = new Scanner(Path.of("people.txt"))) { Pattern pattern = Pattern.compile("4[0-9]"); List<String> ages = sc.findAll(pattern) .map(MatchResult::group) .collect(Collectors.toList()); System.out.println("Ages: " + ages); }
前面的代码选择了所有表示 40-49 岁年龄的标记,即 40、43 和 43。
如果我们希望解析控制台中提供的输入,Scanner
是一种方便的方法:
Scanner scanConsole = new Scanner(System.in); String name = scanConsole.nextLine(); String surname = scanConsole.nextLine(); int age = scanConsole.nextInt(); // an int cannot include "\n" so we need //the next line just to consume the "\n" scanConsole.nextLine(); String city = scanConsole.nextLine();
注意,对于数字输入(通过nextInt()、nextFloat()等读取),我们也需要使用换行符(当我们点击Enter时会出现这种情况)。基本上,Scanner在解析一个数字时不会获取这个字符,因此它将进入下一个标记。如果我们不通过添加一个nextLine()代码行来消耗它,那么从这一点开始,输入将变得不对齐,并导致InputMismatchException类型的异常或过早结束。
JDK10 中引入了支持字符集的Scanner构造器。
我们来看看Scanner和BufferedReader的区别。
扫描器与BufferedReader
那么,我们应该使用Scanner
还是BufferedReader
?好吧,如果我们需要解析这个文件,那么Scanner
就是最好的方法,否则BufferedReader
更合适,对它们进行一个正面比较,会发现:
BufferedReader比Scanner快,因为它不执行任何解析操作。
BufferedReader在阅读方面优于Scanner在句法分析方面优于BufferedReader。
默认情况下,BufferedReader使用 8KB 的缓冲区,Scanner使用 1KB 的缓冲区。
BufferedReader非常适合读取长字符串,而Scanner更适合于短输入。
BufferedReader同步,但Scanner不同步。
Scanner可以使用BufferedReader,反之则不行。如下代码所示:
try (Scanner scanDoubles = new Scanner(Files.newBufferedReader( Path.of("doubles.txt"), StandardCharsets.UTF_8))) { ... }
总结
我们已经到了本章的结尾,在这里我们讨论了各种特定于 I/O 的问题,从操作、行走和监视路径到流文件以及读/写文本和二进制文件的有效方法,我们已经讨论了很多。