IO流2:字符流
相较于字节流,字符流更易于理解,因此我们从字符流开始介绍Java的IO相关技术。字符流的两个抽象基类——Writer和Reader——均位于Java标准类库中的java.io包中。我们同样通过这两个顶层抽象基类来了解字符流的共性特征与共性方法。
1. 字符写入流Writer
1.1 Writer共性内容介绍
API文档描述:
写入字符流的抽象类。
构造方法:
Writer的两个构造方法的访问权限均是protected,表明只能被子类访问。
方法:
抽象方法:close、flush和write(char[]cbuf,int off,int len)方法是抽象的,需要子类复写,并按照不同的需求进行不同的代码实现。
public abstract void close()throwsIOException:关闭此流,但要先刷新它。
public abstract void flush()throwsIOException:刷新该留的缓冲。
关于以上两个方法作用,我们通过下面的例程进行说明。
具体方法:除以上三个方法以外,其他方法都具备实际的方法体。而其中主要的方法就是write,共有以下5种重载形式(包括一个抽象方法):
public void write(int c)throws IOException:写入单个字符。要写入的字符包含在给定整数值的16个地位中,16高位被忽略。
public void write(char[] cbuf)throwsIOException:写入字符数组。
public void write(String str)throwsIOException:写入字符串。
public void write(String str,int off,int len):写入字符串的一部分。
public abstract void write(charp[ cbuf,intoff,int len)throws IOException:写入字符数组的某一部分。该方法为抽象方法,须由子类具体实现。
我们主要需要掌握的就是以上这些write方法。
1.2 Writer子类演示——FileWriter
(1) 文本数据的简单写入
了解了这一继承体系的共性内容以后,我们通过其中一个子类FileWriter来演示Writer类的应用方式。FileWriter的API文档描述为:用来写入字符文件的便捷类。需要提醒大家的是,FileWriter的类名定义原则是:前缀名表示子类的功能,后缀名表示父类名,这同样也适用于其他的IO流类。由于抽象父类中定义了这一体系中的所有方法,而FileWriter类仅仅是具体实现了这些抽象方法,因此其API文档中是没有方法摘要的,我们只需要关注它的构造方法摘要即可。
在FileWriter类若干构造方法中,我们以FileWriter(String fileName)构造方法为例,根据给定的文件名构造一个FileWriter对象。字符串类型参数fileName表示的就是目标文件名(也就是向该文件中写入文字数据)。注意到FileWriter类并没有空参数构造方法,这是因为要实现文字数据的读取和写入,首先必须存在被操作的文件,所以FileWriter对象的创建必须要为其初始化一个文本文件。下面通过以下例程来了解FileWriter类的简单应用。
需求:在硬盘上创建一个文本文件,并写入一些文字数据。
代码1:
//导入io包中的FileWriter与IOException类
import java.io.FileWriter;
import java.io.IOException;
class FileWriterDemo
{
public static void main(String[] args)throws IOException
{
//创建一个FileWrier对象,通过构造方法初始化一个文本文件
//可在文件名前指定文件所在目录,目录之间通过“\\”分隔
FileWriter fw =
new FileWriter("DemoFile.txt");
//通过write方法,向流中的缓冲写入一行文字
//这里调用的是写入一个字符串的write重载方法
fw.write("HelloWorld! ");
//通过flush刷新缓冲
//所谓刷新,就是将流中缓存内的文字数据写入到指定文件中,并清空缓冲
fw.flush();
//重复调用write-flush方法,可继续想文件中追加内容
fw.write("HelloJava!");
fw.flush();
//写入完毕后必须要调用close方法,将流资源关闭
fw.close();
//关闭流后不能再向流中写入文字数据,否则将在运行时抛出异常
//fw.write("Writeafter close");!~ 错误代码
}
}
执行以上代码后,将在DemoFile文件中看到以下内容:
Hello World! Hello Java!
对以上代码做出以下5点说明:
(1) 在上述代码开头除导入了FileWriter类以外,还导入了同在io包中的IOException异常类,这是因为在以上代码中FileWriter构造方法、write、flush以及close方法均声明可能抛出该异常,因此需要对其进行处理,这里为演示方便,暂时将其简单抛出,后面我们将会介绍如何对IOException进行try-catch处理。
(2) 在执行以上代码之前,不需要手动创建指定的txt文件,在创建FileWriter对象的同时就会在指定目录下新建指定文件,而如果指定文件夹中已存在同名文件,同样会新建文件,并将原有文件覆盖,这一点需要大家注意。此外,如果指定的文件名中不包含路径,那么就会默认地在Java源代码所在文件夹下创建指定文件。其实,创建FileWriter对象的同时也就是在明确数据存放的目的地。
(3) 在通过write方法“写入”一行文字以后,又调用了一次flush方法,这样才将指定的文字数据写入到指定文件中。实际上,write方法的作用只是将指定文字数据写入到所谓“流”资源内的一个缓存中,而flush方法的作用就是将缓存内临时存放的文字数据写入到指定的文件中,并清空该缓存,这一动作称为刷新。
(4) 重复write-flush方法,可以不断地将指定文字数据写入到指定文件末尾。
(5) 写入操作结束以后,必须通过调用close方法关闭流资源。而在关闭资源前,同样会进行一次刷新动作。那么所谓“流”资源其实就是Java虚拟机所在系统中,向文件中写入数据的方法,而通过Java语言向文件中写入数据,实际就是在调用“流”这一系统资源来实现的。那么在调用系统资源后就必须关闭资源,以节省系统开销。因此,在关闭了“流”资源以后,也就不能再进行数据写入操作了,因此调用close方法后,再调用write将会在运行时抛出IOException异常,并提示“Stream closed”,意思是流被关闭了。
最后,我们对以上向文件中写入文本数据的过程,做一简单总结:
第一步:创建FileWriter对象(或其他写入流对象),同时初始化一个文件(或其他写入目的地);
第二步:写入指定文本数据;
第三步:刷新缓冲;
第四步:重复以上两个步骤,直到不再写入数据。
第五步:关闭流。
(2) 文本数据的续写
我们知道,通过FileWriter(String fileName)构造方法创建文件写入流对象时,如果指定文件已存在,那么将会创建一个新的同名文件,并将原文件覆盖。而在实际开发过程中,我们常常需要对同一个文件进行反复读写操作,此时我们就可以通过以下构造方法创建文件写入流:
FileWriter(String filename,boolean append)
该构造方法的API文档描述为:根据给定的文件名以及指示是否附加写入数据的boolean值来构造FileWriter对象。通过参数列表中的布尔型变量append来决定是否覆盖已存在的文件。如果append为true,将向原有文件中追加内容,否则,将会创建新文件覆盖原文件。阅读以下代码,
代码2:
import java.io.FileWriter;
import java.io.IOException;
class FileWriterDemo2
{
public static void main(String[] args)throws IOException
{
FileWriter fw =
new FileWriter("DemoFile2.txt",true);//将append参数置为true
fw.write(" bcd ");
fw.flush();
fw.close();
}
}
假设默认路径中已存在DemoFile2.txt文件,内容为“abc”,执行完以上代码后,文件内容将更改为“abc def”,实现了在已有文件基础上文本内容的续写。
在以上内容的基础上,如果想要实现文本内容的换行写入,通常首先想到的就是在指定字符串末尾添加换行符'\n',但仅仅这么做是不够的。这是因为在记事本软件中若要实现换行是必须要在'\n'前再加上回车符'\r',记事本只有在识别到"\r\n"这一字符组合时,才能显示出换行效果。
2. IO异常的正确处理
在前面的内容中我们说到,FileWriter构造方法、write、flush以及close方法均声明可能抛出IOException异常,而为了演示方便,我们并没有对该异常进行针对性的处理,而仅仅将其继续向上一级调用者(虚拟机)抛出,当然这种做法在实际开发中是不可取的,在这一部分内容我们就来说一说如何通过try-catch的方式,来处理不同方法抛出的IOException异常。
我们继续利用前述代码1,
代码3:
import java.io.FileWriter;
import java.io.IOException;
class FileWriterDemo3
{
public static void main(String[] args)
{
//首先创建FileWriter变量,将其初始化为null,以方便后面进行关流动作
FileWriter fw = null;
//将以下三行代码全部定义在try代码块中
try
{
fw = new FileWriter("DemoFile3.txt");
fw.write("HelloWorld!");
fw.flush();
}
//catch代码块接收异常后,这里仅简单打印异常信息
catch(IOException e)
{
System.out.println(e.toString());
}
//由于关流的动作一定要完成,因此将该代码放到finally代码块中
finally
{
//由于调用close方法同样抛出异常,因此也要对其进行try-catch处理
try
{
//在关流以前判断FileWriter变量是否指向空,若非空才进行关流动作
if(fw!= null)
fw.close();
}
catch(IOException e)
{
System.out.println(e.toString());
}
}
}
}
代码说明:
(1) IO异常处理,最基本的思想就是,将构造方法、write、flush以及close方法全部定义在try代码块中,后接catch代码块,接收IOException对象以后,在catch代码块内进行异常处理。但是不能分别对这四个方法进行四次try-catch处理,原因是,假设第一行代码就出现了问题,那么try-catch处理后,后面的所有代码就无法也没有必要再继续执行了。
(2) 在以上四个需要异常处理的方法中,close方法相对比较特殊。因为close方法总是最后才执行,但凡在前三个方法中有一个抛出异常,那么代码的执行将跳转到catch代码块,而无法关闭“流”资源。因此,正确做法是将关闭资源的动作(一定要执行的代码)定义在finally代码块内,并且也要对其进行try-catch处理。如果创建了多个流对象,那么在finally代码块中必须要一一对其进行关流动作。
(3) 由于FileWriter类型变量定义在了第一个try代码块内,因此在finally代码块中是无法访问到fw变量的,这也就导致无法调用close方法完成关流动作。那么这一问题的解决方法是将FileWriter类型变量定义为main方法的局部变量,将其定义到try代码块外,并初始化为null,而在try代码块中令变量指向FileWriter对象即可。
(4) 在创建FileWriter对象时,可能会因为指定错误的文件目录而无法成功创建对象(同时抛出FileNotFoundException异常,未能找到执行文件异常),导致fw变量指向空,而finally代码块中的语句依然会被执行,此时就会出现NullPointerException——空指针异常。因此调用close方法以前,需要先判断该变量是否指向空,只有在不指向空时才执行关流动作。这种判断称为健壮性判断。
以上,就是处理字符写入流IO异常的一般方式,以及定义每一步的原因,而对于下面将要介绍的字符读取流,异常的处理方式基本相似。
3. 字符读取流Reader
3.1 Reader共性内容介绍
在前面的内容中我们介绍了字符写入流的特点与常用方法,下面我们继续介绍字符读取流——Reader的特点及其应用方式。
API文档描述:
用于读取字符流的抽象类。
构造方法摘要:
与Writer相同,两个构造方法的访问修饰符均为protected,只能用于子类访问。
方法摘要:
抽象方法:Reader类同样具备抽象的close方法,表明读取文件同样需要调用系统底层的资源,并且在读取完毕以后,要完成关闭流资源的动作。
public abstract voidclose() throws IOException:关闭该流并释放与值关联的所有资源。注意到,这里并没有涉及到对缓冲的刷新,因为读取实际上就是将读取到的数据存储到缓冲中。
具体方法:与Writer类相对应的,Reader类最重要的方法就是read方法,共有5种重载形式(包括一个抽象方法)
public int read()throws IOException:读取单个字符。注意到该方法的返回值类型为int型值,而不是char,需要大家注意,并且当读到文件末尾时,返回-1。我们将在后面的内容中说明这样设计read方法的原因。
public int read() char[]cbuf) throws IOException:将字符读入数组。
public abstract intread(char[] cbuf,int off,int len) throws IOException:将字符读入数组的某一部分。
public intread(CharBuffer target):试图将字符读入指定的字符缓冲区。
3.2 Reader子类演示——FileReader
1) 通过read方法读取文件
介绍完Reader继承体系的共性内容后,我们通过一个子类——FileReader(文件读取流)来演示字符读取流的使用方法。该类的API文档描述为:用来读取字符文件的便捷类,此类的内部定义了默认的字符编码——GBK(通过System的getProperties方法获取的系统属性信息中,sun.jnu.encoding键所对应的值)。
FileReader类的构造方法与FileWriter同样也是相对应的,需要在初始化对象时,就指定读取的文件,如下构造方法所示:
publicFileReader(String fileName) throws FileNotFoundException
大家要注意,FileReader的构造方法抛出的异常并不是IOException异常。
下面我们就通过以下例程,来初步了解FileReader类的简单应用。假设硬盘中有一个名为DemoFile.txt的记事本文件,其内容为“abc”。
代码4:
import java.io.FileReader;
import java.io.IOException;
class FileReaderDemo
{
public static void main(String[] args) throws IOException//为演示方便,简单抛出异常
{
//创建一个文件读取流对象,并与指定名称的文件相关联
FileReader fr =
new FileReader("DemoFile.txt");
//调用读取流的对象的read方法
//这里以读取单个字符的read方法为例
int ch1 = fr.read();
System.out.println((char)ch1);//将表示字符编码的整数强转为char类型值
//重复以上动作,读取下一个字符
int ch2 = fr.read();
System.out.println((char)ch2);
int ch3 = fr.read();
System.out.println((char)ch3);
//读到文件末尾,返回-1
int ch4 = fr.read();
System.out.println(ch4);//为看到效果,这里不进行强转
//关闭读取流资源
fr.close();
}
}
执行结果为:
a
b
c
-1
代码说明:
(1) 在创建FileReader对象,并与指定文件相关联时,必须要事先保证该文件已存在于指定路径中(如未指定路径,那么默认路径就是该java源代码文件所在路径),否则将抛出IOException异常的子类FileNotFoundException异常。
(2) 于read方法返回的是整型值,因此打印的时候需要将其强转为char类型值。
(3) 由以上执行结果可以看出,每执行一次read方法,它将按顺序自动返回下一个字符所对应的整型值,而当读到文件末尾时就返回了-1。由此我们可以将读取字符的重复动作定义到一个循环当中,而循环结束的条件就是判断read返回值是否是-1。因此对代码4作出如下修改,并附加异常处理代码
代码5:
import java.io.FileReader;
import java.io.IOException;
class FileReaderDemo2
{
public static void main(String[] args)
{
FileReader fr = null;
try
{
fr =
new FileReader("DemoFile.txt");
//定义一个临时变量,用于记录每次读取的字符
int ch = 0;
while((ch = fr.read()) != -1)//当读取到的字符对应整型值为-1时,停止循环
{
System.out.print((char)ch+" ");
}
}
catch(FileNotFoundException e)
{
throw new RuntimeException("指定文件不存在!");
}
catch(IOException e)
{
throw new RuntimeException("读取文件失败!");
}
finally
{
if(fr != null)
{
try
{
fr.close();
}
catch(IOException e)
{
throw new RuntimeException("读取流关闭失败!");
}
}
}
}
执行以上代码结果为:
a b c
显然代码5相比代码4更为简洁。为便于理解记忆,我们简单描述一下read方法的文件读取原理:每一个硬盘中的文件都会占据一块硬盘空间,而为了便于分隔相邻的两个硬盘空间,系统都会在文件的末尾定义一个结束标记。假设在硬盘中存有一个文本文件,通过FileReader对象的read方法,依次读取文件中的字符,并将字符对应的整型值返回,当读取到文件末尾的结束标记时,不是返回这个结束标记,而是返回-1,告诉调用者已达到文件末尾,应停止文件读取动作。
此外,关于以上代码中异常处理的方式,由于FileReader类的构造方法抛出的异常是FileNotFoundException,而该异常是IOException异常的子类,因此要首先捕捉FileNotFoundExceptin,然后再捕捉由read方法抛出的异常。由于这两个异常的发生均不是程序本身的问题,前者是由于外部调用者传递了错误参数,而后者是由于系统底层资源出现了故障,因此对这两个异常,我们能做的就是抛出RuntimeException异常,停止程序的继续执行,修改代码。处理close方法的IOException异常也是同样的道理。
2) 通过带字符缓存的read方法读取文件
从FileReader类的API文档可知,除了一次读取一个字符的read方法以外,还有一个read方法的重载形式,方法定义如下所示,
public intread(char[] cbuf,int offset,int length)
将字符读入数组中的某一部分。字符数组cbuf表示一个缓冲区,offset表示数组的开始存储脚标值,length表示要读取的最大字符数。此外,该read方法的返回值类型虽然也是整型值,但是它返回的是读取到的字符个数,而非字符对应的整型值,不过当读到末尾时,同样返回-1。
那么我们首先来演示一下,如何应用这一read重载方法来读取文件,文本文件内容为“abcedfg”,
代码6:
import java.io.IOException;
import java.io.FileReader;
class FileReaderDemo3
{
public static void main(String[] args) throws IOException
{
FileReader fr =
new FileReader("DemoFile.txt");
//定义一个字符数组作为缓存
char[] cbuf = new char[3];
//调用read方法的同时传递字符数组
//将读到的字符先存储到该字符数组中
int len = fr.read(cbuf);
System.out.println("len="+len+","+newString(cbuf));
//重复以上动作
len = fr.read(cbuf);
System.out.println("len="+len+","+newString(cbuf));
len = fr.read(cbuf);
System.out.println("len="+len+","+newString(cbuf));
//达到文件后,继续读取,将返回-1
len = fr.read(cbuf);
System.out.println("len="+len+","+newString(cbuf));
fr.close();
}
}
执行结果为:
len=3,abc
len=3,def
len=1,gef
len=-1,gef
由于代码6中定义的字符缓存(字符数组)的长度为3,因此每次read方法只能读取并存储3个字符到字符缓存中,相应的返回值也就是3。前两行打印结果是显而易见的,而进行第三次读取操作时,由于只剩下字母“g”,因此返回值为1,但是为什么第三行字符串的后两个字符与第二行字符串的后两个字符相等呢?
其实带字符缓存的read方法,本质上也是一个字符一个字符的读取的,读到一个字符就将该字符存放到字符数组中,以此类推,直到字符数组被填满,返回字符数组被填满前总共读取到的字符个数。继续向后读取字符时,就会再次从数组的第一个位置开始存储,将原有的字符覆盖掉。那么第三行打印结果也就可以理解了,在读取到文件末尾以前,只读到了一个字符“g”,将它存储到字符数组的第一个位置以后,就返回了读取的字符个数1,而此时字符数组的后两个位置依然存储着上一次存储到的两个字符“e”和“f”,因此最终的打印结果就是“gef”。因此最好的做法是通过下面的构造方法创建字符串,
public String(char[]value,int offset,int count)
除了传递字符数组以外,还要指定从字符数组的第一个位置(offset)开始,共将多少个字符(count)转换为字符串。对于最后一行打印结果,由于在读到文件末尾以后,又继续读取了一次,因此read返回值为-1。
当然代码6的文件读取方式是不可取的,我们同样应该通过循环的方式,以简化代码的书写,
代码7:
import java.io.FileReader;
import java.io.IOException;
class FileReaderDemo6
{
public static void main(String[] args) throws IOException
{
FileReader fr =
new FileReader("DemoFile.txt");
//缓存的大小通常定义为1024的整数倍,大小相当于2k
char[] cbuf = new char[1024];
int len = 0;
while((len= fr.read(cbuf)) != -1)
{
System.out.println(newString(cbuf,0,len));//指定字符串的长度
}
fr.close();
}
}
执行结果为:
abcdefg
代码说明:
(1) 字符缓存的长度通常定义为1024的整数倍,这里我们定义为1024,大小相当于2Kb(一个chat类型值大小为1个字节)。
(2) 读取完文件中的7个字符后,read返回值为7,因此newString(cbuf,0,len)表示从字符数组第一个元素开始,共将7个字符转换为字符串,这样做就是不会把字符数组中后面的1017个空格(对应的整型值为0)打印出来了。
(3) 使用带缓存的read方法,其最大的优点在于,可以提高文件的读取速度。第一种读取方式是,读一个字符,打印一个字符;第二中读取方式是,读一个字符先临时存储起来,直到将缓存存满以后,将缓存内的字符一次性全部打印出来,显然第二种方式更为高效。因此在施加开发中,我们应优先使用带缓存的读取方式。
最后,我们对字符读取流的使用步骤做一个简单总结:
第一步,创建一个文本文件读取流,并与指定文件相关联;
第二步,定义一个整型变量用于存储读取到的字符对应的整型值或者是一次读取到的字符个数;
第三步,如果使用带字符缓存的read方法,还需要定义一个字符数组,长度通常定义为1024的整数倍;
第四步,开启while循环中不断从文件读取字符,直到read方法返回值为-1为止。
第五步,关闭读取流资源。
3) 字符读取流练习
需求:读取一个“.java”文件,并打印到控制台。
分析:“.java”文件(Java源代码)本身也是文本文件的一种,因此同样可以被FileReader类读取其中的文本内容,读取方法与前面所讲内容是一样的。这里我们就以前述代码7对应的“.java”文件为例。
代码:
代码8:
import java.io.FileReader;
import java.io.IOException;
class FileReaderDemo7
{
public static void main(String[] args) throws IOException
{
FileReader fr =
new FileReader("D:\\java_samples\\18th_day\\FileReaderDemo6.java");
char[] cbuf = new char[1024];
int len = 0;
while((len= fr.read(cbuf)) != -1)
{
System.out.print(newString(cbuf,0,len));
}
fr.close();
}
}
这里不再显示执行结果,只提醒大家,将字符数组转换为字符串打印到控制台时,不要使用println方法,因为当指定文件大小大于2k时,字符缓存中存储了2k的文本内容后打印到控制台将自动进行换行,但这个换行动作可能并不是原文本中存在的。
4. 字符写入与读取流的结合——文本文件复制
文本文件的复制,实际就是将字符写入流和字符读取流结合起来,原理就是从指定文件A读取文本数据,再将这些文本数据写入到另一个指定文件B中。
我们首先来说一说将上述原理实现为Java代码的步骤:
第一步:首先需要一个文本文件,称为文件A;
第二步:创建一个字符读取流并与文件A关联;再创建一个字符写入流,与文件B关联,文件B与A同名,但必须要保证路径不同;
第三步:开启循环,通过带字符缓存的read方法,从文件A读取文本数据,并将文本数据转换为字符串,调用字符写入流的write方法写入到文件B中。
第四步:读写完毕后,分别关闭读取流和写入流。
按照以上步骤,我们首先使用每次读取一个字符的read方法来实现文件的复制,
代码9:
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
class CopyTest
{
//为演示方便本例程不进行异常处理
public static void main(String[] args) throws IOException
{
FileReader fr =
new FileReader("Youth.txt");
FileWriter fw =
new FileWriter("Youth_copy.txt");
int ch = 0;
while((ch= fr.read()) != -1)
{
fw.write(ch);
fw.flush();
}
fr.close();
fw.close();
}
}
以上代码虽然可以实现文本文件的复制,但正向我们前面提到的那样,效率低下是它的问题,从硬盘上的一块区域读取一个字符,然后写入到硬盘的另一区域,这样一个一个字符的读写,不仅需要执行大量的while循环,而且硬盘磁头需要在两个区域之间来回切换,效率自然无法提高。反之,如果使用带缓存的读取流,从一片区域一次性读取大量字符存储到字符数组中,再将字符数组中的文本数据一次性写入到硬盘的另一篇区域,由于不必每读取一个字符就进行循环判断,循环执行次数和硬盘磁头在两个区域之间的切换次数都将大大降低,提高了代码的执行效率。由此将代码9按照以上思路进行修改,并增加异常处理部分,
代码10:
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.FileNotFoundException;
class CopyTest2
{
public static void main(String[] args)
{
FileReader fr = null;
FileWriter fw = null;
try
{
fr =
new FileReader("Youth.txt");
fw =
new FileWriter("Youth_copy2.txt");
char[] cbuf = new char[1024];
int len = 0;
while((len= fr.read(cbuf)) != -1)
{
fw.write(cbuf,0,len);//调用write重载方法,写入字符数组的指定部分
fw.flush();
}
}
catch(FileNotFoundException e)
{
//当指定文件不存在时,抛出RuntimeException,停止程序的继续执行
throw new RuntimeException("指定文件不存在!");
}
catch(IOException e)
{
//读写失败,同样停止程序执行
throw new RuntimeException("文件读写失败!");
}
finally
{
if(fr != null)
{
try
{
fr.close();
}
catch(IOException e)
{
//关流失败,也应停止程序的执行
throw new RuntimeException("读取流关闭失败");
}
}
if(fw != null)
{
try
{
fw.close();
}
catch(IOException e)
{
//关流失败,也应停止程序的执行
throw new RuntimeException("读取流关闭失败");
}
}
}
}
}
上述代码就是,实现文件复制功能的Java代码的常规书写格式。进行两点说明,
第一点:向文件中写入文本数据时,调用的是write方法重载形式——写入字符缓存的指定部分,这样做可以避免当字符缓存没能填满时,将其余的“空”(值为0)一同写入目的文件中;
第二点:在上述代码中,我们对所有可能产生异常的代码都进行了异常处理,但要注意的是,这只是异常处理格式,实际开发时,应该在catch代码块中根据实际情况定义更有针对性的处理动作。
在前面介绍FileReader和FileWriter类时,已经就各个异常的处理介绍了常规定义格式,这里不再赘述,需要强调的是,应根据实际开发需求,定义有针对性的catch代码块处理代码,仅仅抛出RuntimeException,停止程序执行并非是唯一的方式。
5. 字符流的缓冲区技术
在前面的内容中我们介绍了文件读取流(FileReader)与文件写入流(FileWriter)的基本用法,通过操作这两个类实现了对文本文件的读取与写入操作。同时我们也提到,如果通过一次操作一个字符的read和write方法,程序的执行效率将会比较低下——需要进行大量的循环以及需要在两个硬盘区域之间频繁进行切换和读写动作。因此为了提高程序的执行效率我们介绍了通过指定一个字符缓存(字符数组)作为中转实现读写的read和write方法。虽然通过手动指定字符缓存的方法可以提高代码执行效率,但是却使代码变得复杂。因此,为了在提高代码的执行效率的同时,降低代码的复杂度,Java标准类库提供了在内部封装了字符缓存的字符缓存流——BufferedReader和BufferedWriter。
BufferedReader和BufferedWriter同样分别是Reader和Writer的子类,很容易想到这两个类的方法和读取文件的方式与FileReader和FileWriter是基本相同的。
5.1 BufferedWriter
(1) BufferedWriter类简介
API文档描述:
将文本写入字符输出流,缓冲各个字符,从而提供单个字符、数组和字符串的高效写入。
构造方法:
publicBufferedWriter(Writer out):创建一个使用默认大小输出缓冲区的缓冲字符输出流。
publicBufferedWriter(Writer out,int sz):创建一个使用给定大小输出缓冲区的新缓冲字符输出流。
注意到,BufferedWriter类是没有空参数构造方法的,这是因为字符缓冲流类是不能独立进行写入操作的,它的作用仅仅是为其他写入流对象提供缓冲功能,提高流的操作效率,因此创建BufferedWriter对象的时候,必须要为其初始化一个能够独立实现写入操作的流对象,比如FileWriter。
第二种构造方法的参数sz,是用于指定缓冲区的大小的。
方法:
大部分方法与FileWriter类是相同的,这里只强调其中的1个。
public void newLine():写入一个行分隔符。其作用就是向文件中写入一个换行符,那么之所以要为换行动作定义一个方法,是因为不同系统中的换行符是不同的,比如在Windows系统中必须输入“\r\n”组合符号才能实现换行,而在Linux系统中“\n”就可以实现换行,那么该方法也就体现了Java语言的跨平台特性了——不论在哪个系统下运行程序,都不需要修改代码。
(2) BufferedWriter应用演示
我们通过下面的例程演示,字符缓冲流的使用方法,
代码11:
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
class BufferedWriterDemo
{
public static void main(String[] args)
{
//为方便关流,将指向写入流的变量定义在异常处理代码块之外
BufferedWriter bufw = null;
try
{
//创建字符缓冲写入流对象的同时,为其初始化字符写入流对象
//同时与目的文件关联
bufw =
new BufferedWriter(newFileWriter("DestFile.txt"));
for(int x=1; x<=10; x++)
{
bufw.write("第"+x+"行");
//通过newLine方法写入换行符,实现文本的换行
bufw.newLine();
//对于字符缓冲写入流,必须进行刷新动作
bufw.flush();
}
}
catch(IOException e)
{
throw new RuntimeException("写入数据失败!");
}
finally
{
try
{
if(bufw != null)
//关闭写入流只需调用字符缓冲写入流的close方法即可
bufw.close();
}
catch(IOException e)
{
throw new RuntimeException("写入流关闭失败!");
}
}
}
}
执行以上代码后,在DestFile.txt文件中的内容是:
第1行
第2行
第3行
第4行
第5行
第6行
第7行
第8行
第9行
第10行
代码说明:
(1) 如前所述,若要应用字符缓冲流必须首先要有字符写入流,因此在创建BufferedWriter对象的同时为其初始化了一个FileWriter对象,并与目的文件进行了关联。
(2) 每调用一次write方法写入一行文本数据,就可以通过调用newLine方法进行换行,而不必手动写入“\r\n”或者“\n”,体现了Java语言的跨平台性。但要注意,该方法只限于字符缓冲写入流类,其他写入流类是没有的。
(3) 对于字符缓冲写入流,若要将字符数据写入到文件中,必须调用flush方法进行刷新,并且尽可能每调用一次write方法写入一行字符数据,就进行一次刷新动作,这样即使由于发生意外而无法继续写入数据时,至少可以保留之前写入的数据。
(4) 关闭写入流资源时,只需通过BufferedWriter对象即可完成,而不必分别调用BufferedWriter和FileWriter的close方法。这是因为,BufferedWriter类的存在仅仅是为了提高字符写入流的写入效率而提供了缓冲技术,而真正利用底层流资源完成写入功能的是FileWriter对象,因此BufferedWriter类的close方法实际就是在调用它所提高写入效率的流对象——FileWriter——的close方法。
5.2 BufferedReader
(1) BufferedReader简介
API文档描述:
从字符输入流中读取文本,缓冲各个字符,从而实现字符、数组和行的高效读取。
构造方法:
public BufferedReader(Reader in):创建一个使用默认大小输入缓冲区的缓冲字符输入流。
public BufferedReader(Reader in,int sz):创建一个使用指定大小输入缓冲区的缓冲字符输入流。
缓冲字符输入流的构造方法定义与缓冲字符写入流是类似的,同样需要在创建对象时为其初始化一个字符输入流对象,原因同缓冲字符写入流,这里不再赘述。
方法:
由于BufferedReader直接继承自Reader,因此其大部分方法与Reader是相同的,这里只强调其中一个:
public StringreadLine() throws IOException:读取一个文本行。通过下列字符之一即可认为某行已终止:换行(’\n’)、回车(’\r’)或回车后直接跟着换行。该方法有别于读取一个字符,或者将读取到的字符存储到字符数组中的方法,而是通过判断回车符,直接返回由文本中一行的字符转换而来的字符串,这对于操作文本文件是非常方便的。需要注意两点,readLine方法返回的字符串不包含任何行终止符,包括换行符,所以如果需要将读取的一行文本再写入到另一个文件中时,应该手动写入换行符;如果读到文件末尾,将会返回null。
(2) BufferedReader应用演示
代码12:
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.FileNotFoundException;
class BufferedReaderDemo
{
public static void main(String[] args)
{
BufferedReader bufr = null;
try
{
//创建缓冲字符读取流对象,并为其初始化一个字符读取流对象
//并与被读取文件相关联
//DestFile为代码11中创建的文本文件
bufr =
new BufferedReader(new FileReader("DestFile.txt"));
//用于临时存储读取到的一行文本
String line = null;
//开启while循环,不断读取文本,知道readLine方法的返回值为null
while((line= bufr.readLine()) != null)
{
System.out.println(line);
}
}
catch(FileNotFoundException e)
{
throw new RuntimeException("指定文件不存在!");
}
catch(IOException e)
{
throw new RuntimeException("读取文件失败!");
}
finally
{
try
{
if(bufr != null)
bufr.close();
}
catch(IOException e)
{
throw new RuntimeException("读取流关闭失败!");
}
}
}
}
上述代码的执行结果为:
第1行
第2行
第3行
第4行
第5行
第6行
第7行
第8行
第9行
第10行
5.3 缓冲字符流练习
练习一:文本文件的复制
介绍完两个分别用于读和写的缓冲字符流,我们将两者结合起来,同样来实现文本文件的复制。
需求:通过缓冲字符流,完成文本文件的复制。
代码:
代码13:
import java.io.*;
class CopyTestByBuf
{
public static void main(String[] args)
{
BufferedReader bufr = null;
BufferedWriter bufw = null;
try
{
bufr =
new BufferedReader(new FileReader("Youth.txt"));
bufw =
new BufferedWriter(new FileWriter("Youth_copy.txt"));
String line = null;
while((line= bufr.readLine()) != null)
{
bufw.write(line);
bufw.newLine();
bufw.flush();
}
}
catch(FileNotFoundException e)
{
throw new RuntimeException("指定文件不存在!");
}
catch(IOException e)
{
throw new RuntimeException("文件读写失败!");
}
finally
{
try
{
if(bufr != null)
bufr.close();
}
catch(IOException e)
{
throw new RuntimeException("读取流关闭失败!");
}
try
{
if(bufw != null)
bufw.close();
}
catch(IOException e)
{
throw new RuntimeException("写入流关闭失败!");
}
}
}
}
以上代码即可实现对文本文件的复制功能。其原理与通过FileReader和FileWriter类实现功能是基本类似的,区别在于前者使用了一个字符数组(字符缓存)和用于记录读取的字符个数的整型变量作为两个流之间的中转站——临时存储被操作的字符,而在本例中,String类型变量line起到了中转的作用。
练习二:自定义BufferedReader类
(1) readLine方法原理
在完成这个练习以前我们首先需要了解BufferedReader类最重要的方法——readLine的底层原理。虽然,该方法可以一次返回一行文本的字符串,但最基本的原理还是通过调用FileReader类一次读取一个字符的read方法来实现的。
假设有一个记事本文件,内容如下所示,
abc\r\n
def\r\n
readLine方法从字符“a”开始依次将读取到的字符存储到定义在BufferedfReader类内部的字符数组(缓存)中,由于字符串中不能包含任何行终止符,因此存储前需要进行两步判断:首先判断该字符是否是“\r”,如果是,将不存储到数组中,并继续向后读取;接着判断是否是“\n”,如果是,就将目前为止存储到字符数组中的字符转换为字符串并返回,那么第一行返回结果就是“abc”。然后,接着读取下一行文本,将字符数组中的字符从头依次覆盖掉,转换为字符串并返回,以此类推。
(2) 自定义BufferedReader类
需求:自定义BufferedReader,并实现文本文件的复制。
分析:为演示方便,只为自定义BufferedReader定义myReaderLine和myClose方法,以实现最基本的文件读取功能。myReadLine方法按照上述原理实现,但为了简化代码,我们不使用字符数组而是采用StringBuilder作为存放一行字符数据的临时容器。myClose方法,只需要调用传入到构造方法中的字符读取流对象的close方法即可。
代码:
代码14:为缩短篇幅,不进行异常处理。
import java.io.*;
class MyBufferedReader
{
private Reader r;
//定义用于临时存储一行字符的容器,这里使用StringBuilder
private StringBuilder sb = new StringBuilder();
MyBufferedReader(Reader r)
{
//初始化一个需要提高效率的字符读取流对象
this.r = r;
}
//定义myReadLine方法
public String myReadLine() throws IOException
{
int ch = 0;
String line = null;
while((ch= r.read()) != -1)//基本原理就是调用字符读取流读取单个字符的方法
{
//读取到回车符,则继续向后读取
if(ch == '\r')
continue;
//读取到换行符,表示一行结束,返回临时容器中的文本数据
elseif(ch == '\n')
{
line = sb.toString();
//返回一行字符串以前,必须清空StringBuilder临时容器
sb.delete(0,sb.length());
return line;
}
//如果是一般字符,则存储到临时容器中
sb.append((char)ch);
}
/*
读取到文件末尾后,临时容器中仍存有字符,那么将这些字符转为字符串返回
否则,返回空
*/
if(sb.length() != 0)
{
line = sb.toString();
sb.delete(0,sb.length());
return line;
}
return null;
}
public void myClose() throws IOException
{
r.close();
}
}
class MyBufferedReaderDemo3
{
public static void main(String[] args) throws IOException
{
MyBufferedReader mbufr =
new MyBufferedReader(new FileReader("Youth.txt"));
BufferedWriter bufw =
new BufferedWriter(new FileWriter("Youth_copy.txt"));
String line = null;
while((line= mbufr.myReadLine()) != null)
{
bufw.write(line);
bufw.newLine();
bufw.flush();
}
mbufr.myClose();
bufw.close();
}
}
代码说明:
(1) StringBuilder作为存放一行字符数据的临时容器,由于是成员变量,因此必须将其中的内容转为字符串返回后,清空容器,否则以后每次返回的字符串都将包含以前的内容。
(2) 在读取到文件末尾时,如果文件最后没有换行,那么就会由于read方法返回-1而导致未将SringBuilder临时容器中最后存有的字符返回,while循环就会结束。因此循环结束后必须判断容器中是否还存有字符(通过StringBuilder类的length方法,返回0表示容器中不包含字符),如果有,那么就将这部分字符返回,然后清空容器,此后再调用myReadLine方法会直接返回null了,至此文件读取才完全结束。
(3) myReadLine和myClose方法由于分别调用了Writer子类对象的read和close方法而抛出IOException,但是我们在定义这两个方法时,不能在方法内部try-catch处理的,因为异常发生的原因有很多,在实际开发过程中,应按照实际情况进行处理,如果在方法内部处理,就相当于将问题隐藏了起来。因此,我们要遵循的异常处理原则是:谁调用谁处理。
(4) 其实,StringBuilder临时容器对象是可以定义在myReadLine方法内部作为局部变量存在的,这样还可以免去清空容器的麻烦。而之所以将其定义为成员变量,是因为如果读取的文件较大,将会频繁调用myReadLine方法,创建大量的StringBuilder对象,这不利于优化内存,因此将其定义为成员变量。
6. LineNumberReader
6.1 LineNumberReader简介
在这片博客的最后,我们再介绍缓冲字符流的一个子类——LineNumberReader。该类由于是BufferedReader的子类,因此同样可以实现整行文本的读取,而其特点在于可以获取到每一行文本的行编号,该类的类名直观的表现了这样一个特点。
API文档描述:
跟踪行号的缓冲字符输入流。此类定义了方法setLineNumber(int)和getLineNumber(),它们可分别用于设置和获取当前行号。默认情况下,行编号从0开始。
构造方法:
与BufferedReader是一样的,这里不再赘述。
方法:
由于继承自BufferdReader,因此LineNumberReader的大部分方法与其父类是相同的(包括readLine),这里只提最能体现其特点的两个方法——getLineNumber和serLineNumber。
public intgetLineNumber():获取当前行号。
public voidsetLineNumber(int lineNumber):设置当前行号。
6.2 LineNumberReader方法演示
由于该类基本的应用方式与BufferedReader是一样的,因此不再过多的进行文字说明,我们直接通过以下例程了解LineNumberReader的特点及用途。
代码15:
import java.io.*;
class LineNumberReaderDemo
{
public static void main(String[] args)
{
LineNumberReader lnr = null;
try
{
lnr =
new LineNumberReader(new FileReader("Youth.txt"));
String line = null;
lnr.setLineNumber(100);//设置行号从100起始
while((line = lnr.readLine()) != null)
{
//打印每一行内容的同时,打印对应的行号
System.out.println(lnr.getLineNumber()+":"+line);
}
}
catch(FileNotFoundException e)
{
thrownew RuntimeException("指定文件不存在");
}
catch(IOExceptione)
{
throw new RuntimeException("读取文件失败!");
}
finally
{
try
{
if(lnr != null)
lnr.close();
}
catch(IOException e)
{
throw new RuntimeException("读取流关闭失败!");
}
}
}
}
上述代码的执行结果为:
101:Youth is not a time of life;
102:it is a state of mind;
103:it is not a matter of rosy cheeks,red lips and supple knees;
104:it is a matter of the will, aquality of the imagination, a vigor of the emotions;
105:it is the freshness of the deepsprings of life.
106:
107:Youth means a temperamental predominanceof courage over timidity,
108:of the appetite for adventure overthe love of ease.
109:This often exists in a man of 60more than a boy of 20.
110:Nobody grows old merely by anumber of years.
111:We grow old by deserting ourideals.
112:
113:Years may wrinkle the skin, but togive up enthusiasm wrinkles the soul.
114:Worry, fear, self-distrust bowsthe heart and turns the spirit back to dust.
115:
116:Whether 60 or 16, there is inevery human being’s heart the lure of wonders,
117:the unfailing appetite for what’snext and the joy of the game of living.
118:In the center of your heart and myheart, there is a wireless station;
119:so long as it receives messages ofbeauty, hope, courage and power from man and from the infinite,
120:so long as you are young.
121:
122:When your aerials are down,
123:and your spirit is covered withsnows of cynicism and the ice of pessimism,
124:then you’ve grown old, even at 20;
125:but as long as your aerials areup,
126:to catch waves of optimism,
127:there’s hope you may die young at80.
在读取文本数据以前,设置行编号的起始值为100,并在每一行文本前加上行编号,即可得到以上打印结果。
6.3 自定义LineNumberReader
实际上LineNumberReader的最基本读取原理与BufferedReader是一样的,唯一的区别在于多定义了一个私有成员变量——lineNumber,用于记录每行的行编号。那么相应的需要为这个成员变量定义获取和设置的方法——setLineNumber和getLineNumber。而关于行号最关键的问题就是行号自增的时机——只要readLine方法被调用,行号就应该自增一次,所以要在readLine方法的起始处对行号变量进行自增操作。阅读以下例程代码,
代码16:
import java.io.*;
class MyLineNumberReader extendsBufferedReader
{
//表示记录行编号的整型变量
private int lineNumber;
private StringBuilder sb = new StringBuilder();
MyLineNumberReader(Reader in)
{
//只需调用父类构造方法,即可为参数Reader子类对象进行初始化
super(in);
}
//设置行编号
public void setLineNumber(int lineNumber)
{
this.lineNumber = lineNumber;
}
//获取行编号
public int getLineNumber()
{
return lineNumber;
}
public String readLine() throws IOException
{
//只要调用readLine方法,就表示读了一行文本
//lineNumber变量就需要自增一次
lineNumber++;
//不必重新定义读取方法,直接调用父类的readLine方法即可
return super.readLine();
}
}
class LineNumberReaderDemo2
{
public static void main(String[] args) throws IOException
{
MyLineNumberReader mlnr =
new MyLineNumberReader(new FileReader("Youth.txt"));
String line = null;
mlnr.setLineNumber(100);
while((line= mlnr.readLine()) != null)
{
System.out.println(mlnr.getLineNumber()+":"+line);
}
mlnr.close();
}
}
执行结果与代码15的结果是一样的,这里不再重复。
代码说明:
(1) 由于父类BufferedReader中已定义了私有的Reader类型成员变量,并在构造方法中完成了对该变量的初始化动作,因此LineNumberReader直接继承该变量,并在其构造方法的第一行调用父类的构造方法即可完成初始化。
(2) readLine方法的主要部分,也已在父类中定义,只需在令行编号自增后,通过super关键字调用父类readLine方法,并返回一行文本即可。其他所有方法都不再需要复写,只需从父类继承。
(3) 注意到,虽然设置的行编号起始值为100,但实际打印结果是从101开始的,这是因为行编号总是首先自增,然后读取文本的缘故。而readLine方法是以return语句结尾的,因此行编号自增的动作只能定义在方法起始位置。
需要大家注意的是,Java标准类库中,LineNumberReader的实际定义内容并非像以上所述那么简单,它几乎复写了每个从父类继承的方法,有兴趣的朋友可以查阅其源代码。