12: Java I/O 系统
对编程语言的设计者来说,创建一套好的输入输出(I/O)系统,是一项难度极高的任务。
File 类
在介绍直接从流里读写数据的类之前,我们先介绍一下处理文件和目录的类。
你会认为这是一个关于文件的类,但它不是。你可以用它来表示某个文件的名字,也可以用它来表示目录里一组文件的名字。如果它表示的是一组文件,那么你还可以用list( )方法来进行查询,让它会返回String数组。由于元素数量是固定的,因此数组会比容器更好一些。如果你想要获取另一个目录的清单,再建一个File对象就是了。
目录列表器
假设你想看看这个目录。有两个办法。一是不带参数调用list( )。它返回的是File对象所含内容的完整清单。但是,如果你要的是一个"限制性列表(restricted list)"的话 —— 比方说,你想看看所有扩展名为.java的文件 —— 那么你就得使用"目录过滤器"了。这是一个专门负责挑选显示File对象的内容的类。
FilenameFilter接口的声明:
public interface FilenameFilter {
boolean accept(File dir, String name);
}
|
accept( )方法需要两个参数,一个是File对象,表示这个文件是在哪个目录里面的;另一个是String,表示文件名。虽然你可以忽略它们中的一个,甚至两个都不管,但是你大概总得用一下文件名吧。记住,list( )会对目录里的每个文件调用accept( ),并以此判断是不是把它包括到返回值里;这个判断依据就是accept( )的返回值。
切记,文件名里不能有路径信息。为此你只要用一个String对象来创建File对象,然后再调用这个File对象的getName( )就可以了。它会帮你剥离路径信息(以一种平台无关的方式)。然后再在accept( )里面用正则表达式(regular expression)的matcher对象判断,regex是否与文件名相匹配。兜完这个圈子,list( )方法返回了一个数组。
匿名内部类
注意,filter( )的参数必须是final的。要想在匿名内部类里使用其作用域之外的对象,只能这么做。
可以用匿名内部类来创建专门供特定问题用的,一次性的类。这种做法的好处是,它能把解决某个问题的代码全都集中到一个地方。但是从另一角度来说,这样做会使代码的可读性变差,所以要慎重。
查看与创建目录
File类的功能不仅限于显示文件或目录。它还能帮你创建新的目录甚至是目录路径(directory
path),如果目录不存在的话。此外它还能用来检查文件的属性(大小,上次修改的日期,读写权限等),判断File对象表示的是文件还是目录,以及删除文件。
renameTo( )这个方法会把文件重命名成(或者说移动到)新的目录,也就是参数所给出的目录。而参数本身就是一个File对象。这个方法也适用于目录。
输入与输出
I/O类库常使用"流(stream)"这种抽象。所谓"流"是一种能生成或接受数据的,代表数据的源和目标的对象。流把I/O设备内部的具体操作给隐藏起来了。
Java的I/O类库分成输入和输出两大部分。所有InputStream和Reader的派生类都有一个基本的,继承下来的,能读取单个或byte数组的read( )方法。同理,所有OutputStream和Writer的派生类都有一个基本的,能写入单个或byte数组的write( )方法。但通常情况下,你是不会去用这些方法的;它们是给其它类用的 —— 而后者会提供一些更实用的接口。因此,你很少会碰到只用一个类就能创建一个流的情形,实际上你得把多个对象叠起来,并以此来获取所需的功能。Java的流类库之所以会那么让人犯晕,最主要的原因就是"你必须为创建一个流而动用多个对象"。
InputStream的种类
InputStream的任务就是代表那些能从各种输入源获取数据的类。这些源包括:
- byte数组
- String对象
- 文件
- 类似流水线的"管道(pipe)"。把东西从一头放进去,让它从另一头出来。
- 一个"流的序列(A sequence of other streams)",可以将它们组装成一个单独的流。
- 其它源,比如Internet的连接。(这部分内容在Thinking in Enterprise Java中讨论。)
这些数据源各自都有与之相对应的InputStream的子类。此外,FilterInputStream也是InputStream的子类,其作用是为基类提供"decorator(修饰)"类,而decorator又是为InputStream配置属性和接口的。
表12-1. InputStream的种类
|
|
类
|
功能
|
构造函数的参数
|
|
用法
|
|
ByteArrayInputStream
|
以缓冲区内存为InputStream
|
要从中提取byte的那个缓冲区
|
|
一种数据源:要把它连到FilterInputStream对象,由后者提供接口。
|
|
StringBufferInputStream
|
以String为InputStream
|
需要一个String对象。实际上程序内部用的是StringBuffer。
|
|
一种数据源:要把它连到FilterInputStream对象,由后者提供接口。
|
|
FileInputStream
|
专门用来读文件的
|
一个表示文件名的String对象,也可以是File或
FileDescriptor对象。
|
|
一种数据源:要把它连到FilterInputStream对象,由后者提供接口。
|
|
PipedInputStream
|
从PipedOutputStream提取数据。实现"管道"功能。
|
PipedOutputStream
|
|
一种多线程环境下的数据源,把它连到FilterInputStream对象,由后者提供的接口。
|
|
SequenceInputStream
|
将两个或更多的InputStream合并成一个InputStream。
|
两个InputStream对象,或一个InputSteam对象容器的Enumerator
|
|
一种数据源:要把它连到FilterInputStream对象,由后者提供接口。
|
|
FilterInputStream
|
一个为decorator定义接口用的抽象类。而decorator的作用是为InputStream实现具体的功能。详见表12-3。
|
见表 12-3
|
|
见表 12-3
|
|
OutputStream的种类
这部分都是些决定往哪里输出的类:是byte的数组(不能是String;不过你可以根据byte数组创建字符串)还是文件,或者是"管道"。
此外,FilterOutputStream还是decorator类的基类。它会为OutputStream安装属性和适用的接口。
表12-2. OutputStream的种类
|
|
类
|
功能
|
构造函数的参数
|
|
用法
|
|
ByteArrayOutputStream
|
在内存里创建一个缓冲区。数据送到流里就是写入这个缓冲区。
|
缓冲区初始大小,可选。
|
|
要想为数据指定目标,可以用FilterOutputStream对其进行包装,并提供接口。
|
|
FileOutputStream
|
将数据写入文件。
|
一个表示文件名的字符串,也可以是File或FileDescriptor对象。
|
|
要想为数据指定目标,可以用FilterOutputStream对其进行包装,并提供接口。
|
|
PipedOutputStream
|
写入这个流的数据,最终都会成为与之相关联的PipedInputStream的数据源。否则就不成其为"管道"了。
|
PipedInputStream
|
|
要想在多线程环境下为数据指定目标,可以用FilterOutputStream对其进行包装,并提供接口。
|
|
FilterOutputStream
|
一个给decorator提供接口用的抽象类。而decorator的作用是为OutputStream实现具体的功能。详见表12-4
|
见表12-4
|
|
见表12-4
|
|
添加属性与适用的接口
使用"分层对象(layered objects)",为单个对象动态地,透明地添加功能的做法,被称为Decorator
Pattern。(模式是Thinking
in Patterns (with Java)的主题。)Decorator模式要求所有包覆在原始对象之外的对象,都必须具有与之完全相同的接口。这使得decorator的用法变得非常的透明--无论对象是否被decorate过,传给它的消息总是相同的。这也是Java I/O类库要有"filter(过滤器)"类的原因:抽象的"filter"类是所有decorator的基类。(decorator必须具有与它要包装的对象的全部接口,但是decorator可以扩展这个接口,由此就衍生出了很多"filter"类)。
Decorator模式常用于如下的情形:如果用继承来解决各种需求的话,类的数量会多到不切实际的地步。Java的I/O类库需要提供很多功能的组合,于是decorator模式就有了用武之地。但是decorator有个缺点,在提高编程的灵活性的同时(因为你能很容易地混合和匹配属性),也使代码变得更复杂了。Java的I/O类库之所以会这么怪,就是因为它"必须为一个I/O对象创建很多类",也就是为一个"核心"I/O类加上很多decorator。
为InputStream和OutputStream定义decorator类接口的类,分别是FilterInputStream和FilterOutputStream。这两个名字都起得不怎么样。FilterInputStream和FilterOutputStream都继承自I/O类库的基类InputStream和OutputStream,这是decorator模式的关键(惟有这样decorator类的接口才能与它要服务的对象的完全相同)。
用FilterInputStream读取InputStream
FilterInputStream及其派生类有两项重要任务。DataInputStream可以读取各种primitive及String。(所有的方法都以"read"打头,比如readByte( ), readFloat( ))。它,以及它的搭档DataOutputStream,能让你通过流将primitive数据从一个地方导到另一个地方。这些"地方"都列在表12-1里。
其它的类都是用来修改InputStream的内部行为的:是不是做缓冲,是不是知道它所读取的行信息(允许你读取行号或设定行号),是不是会弹出单个字符。后两个看上去更像是给编译器用的(也就是说,它们大概是为Java编译器设计的),所以通常情况下,你是不大会用到它们的。
不论你用哪种I/O设备,输入的时候,最好都做缓冲。所以对I/O类库来说,比较明智的做法还是把不缓冲当特例(或者去直接调用方法),而不是像现在这样把缓冲当作特例。
表12-3. FilterInputStream的种类
|
|
类
|
功能
|
构造函数的参数
|
|
用法
|
|
DataInputStream
|
与DataOutputStream配合使用,这样你就能以一种"可携带的方式(portable fashion)"从流里读取primitives了(int,char,long等)
|
InputStream
|
|
包含了一整套读取primitive数据的接口。
|
|
BufferedInputStream
|
用这个类来解决"每次要用数据的时候都要进行物理读取"的问题。你的意思是"用缓冲区。"
|
InputStream,以及可选的缓冲区的容量
|
|
它本身并不提供接口,只是提供一个缓冲区。需要连到一个"有接口的对象(interface object)"。
|
|
LineNumberInputStream
|
跟踪输入流的行号;有getLineNumber( )和setLineNumber(int)方法
|
InputStream
|
|
只是加一个行号,所以还得连一个"有接口的对象"。
|
|
PushbackInputStream
|
有一个"弹压单字节"的缓冲区(has a one byte push-back buffer),这样你就能把最后读到的那个字节再压回去了。
|
InputStream
|
|
主要用于编译器的扫描程序。可能是为支持Java的编译器而设计的。用的机会不多。
|
|
用FilterOutputStream往OutputStream里面写东西
DataInputStream的另一半是DataOutputStream。它的任务是把primitive数据和String对象重新组织成流,这样其它机器就能用DataInputStream读取这个流了。DataOutputStream的方法都是以"write"开头的,比如writeByte( ),writeFloat( )等等。
PrintStream的用意是要以一种大家都能看懂的方式把primitive数据和String对象打印出来。这一点同DataOutputStream不同,后者是要将数据装入一个流,然后再交给
DataInputStream处理。
PrintStream的两个最重要的方法是print( )和println( )。这两个方法都已经作了重载,因此可以打印各种数据。print( )和println( )的区别在于,后者会多打印一个换行符。
使用PrintStream的时候会比较麻烦,因为它会捕捉所有的IOException(所以你必须直接调用checkError( )来检查错误条件,因为这个方法会在碰到问题的时候返回true)。再加上,PrintStream的国际化做得也不好,而且还不能以与平台无关的方式处理换行(这些问题都已经在PrintWriter里得到解决,我们接下来再讲)。
BufferedOutputStream 是个decorator,它表示对流作缓冲,这样每次往流里写东西的时候它就不会再每次都作物理操作了。输出的时候大致都要这么做。
表12-4. FilterOutputStream的种类
|
|
类
|
功能
|
构造函数的参数
|
|
用法
|
|
DataOutputStream
|
与DataInputStream配合使用,这样你就可以用一种"可携带的方式(portable fashion)"往流里写primitive了(int, char, long,等)
|
OutputStream
|
|
包括写入primitive数据的全套接口。
|
|
PrintStream
|
负责生成带格式的输出(formatted output)。DataOutputStrem负责数据的存储,而PrintStream负责数据的显示。
|
一个OutputStream以及一个可选的boolean值。这个boolean值表示,要不要清空换行符后面的缓冲区。
|
|
应该是OutputStream对象的最终包覆层。用的机会很多。
|
|
BufferedOutputStream
|
用 这个类解决"每次往流里写数据,都要进行物理操作"的问题。也就是说"用缓冲区"。用flush(
)清空缓冲区。
|
OutputStream, 以及一个可选的缓冲区大小
|
|
本身并不提供接口,只是加了一个缓冲区。需要链接一个有接口的对象。
|
|
Reader 和
Writer类系
Java 1.1对最底层的I/O流类库作了重大修改。第一次看到Reader和Writer的时候,你会觉得"它们大概是用来取代InputStream和OutputStream的" (和我一样)。但事实并非如此。虽然InputStream和OutputStream的某些功能已经淘汰了(如果你继续使用,编译器就会发警告),但它们仍然提供了很多很有价值的,面向byte的I/O功能,而Reader和Writer则提供了Unicode兼容的,面向字符的I/O功能。此外:
- Java 1.1还对InputStream和OutputStream作了新的补充,所以很明显这两个类系并没有被完全替代。
- 有时,你还必须同时使用"基于byte的类"和"基于字符的类"。为此,它还提供了两个"适配器(adapter)"类。InputStreamReader负责将InputStream转化成Reader,而OutputStreamWriter则将OutputStream转化成Writer。
Reader和Writer要解决的,最主要的问题就是国际化。原先的I/O类库只支持8位的字节流,因此不可能很好地处理16位的Unicode字符流。Unicode是国际化的字符集(更何况Java内置的char就是16位的Unicode字符),这样加了Reader和Writer之后,所有的I/O就都支持Unicode了。此外新类库的性能也比旧的好。
数据源和目的
几乎所有的Java I/O流都有与之对应的,专门用来处理Unicode的Reader和Writer。但有时,面向byte的InputStream和OutputStream才是正确的选择;特别是java.util.zip;它的类都是面向byte的。所以最明智的做法是,先用Reader和Writer,等到必须要用面向byte的类库时,你自然会知道的,因为程序编译不过去了。
下面这张表格列出了这两个类系的数据源和目的之间的关系(也就是说,在这两个类系里,数据是从哪里来的,又是到那里去的)。
数据源和目的
|
Java 1.0的类
|
Java 1.1的类
|
InputStream
|
Reader的适配器:InputStreamReader
|
OutputStream
|
Writer的适配器: OutputStreamWriter
|
FileInputStream
|
FileReader
|
FileOutputStream
|
FileWriter
|
StringBufferInputStream
|
StringReader
|
(没有对应的类)
|
StringWriter
|
ByteArrayInputStream
|
CharArrayReader
|
ByteArrayOutputStream
|
CharArrayWriter
|
PipedInputStream
|
PipedReader
|
PipedOutputStream
|
PipedWriter
|
总之,这两个类系即便不是一摸一样,也至少是非常相像。
修改流的行为
不管是InputStream还是OutputStream,用的时候都要先交给FilterInputStream和FilterOutputStrem,并由后者,也就是decorator做一番改造。Reader和Writer继承了这一传统,不过不是完全照搬。
下面这张表的对应关系比前面那张更粗略。这是因为这两个类系的组织结构不同。比方说BufferedOutputStream是FilterOutputStream的子类,但BufferedWriter却不是FilterWriter的子类(后者虽然是一个abstract类,但却没有子类,所以它看上去只是起一个"占位子"的作用,这样你就不会去惦记它在哪里了)。但不管怎么说,它们的接口还是很相似的。
Filter类
|
Java 1.0的类
|
Java 1.1的类
|
FilterInputStream
|
FilterReader
|
FilterOutputStream
|
FilterWriter(这是个无派生类的抽象类)
|
BufferedInputStream
|
BufferedReader(也有readLine( ))
|
BufferedOutputStream
|
BufferedWriter
|
DataInputStream
|
尽量用DataInputStream (除非你用BufferedReader的时候要用readLine( ))
|
PrintStream
|
PrintWriter
|
LineNumberInputStream(过时了)
|
LineNumberReader
|
StreamTokenizer
|
StreamTokenizer(换一个构造函数,把Reader当参数传给它)
|
PushBackInputStream
|
PushBackReader
|
有一条很清楚:别再用DataInputStream的readLine( )(编译时会警告你这个方法已经"过时了(deprecated)"),要用就用BufferedReader的。此外,DataInputStream仍然是I/O类库的"种子选手"。
为了让向PrintWriter的过渡变得更简单,PrintWriter除了有一个拿Writer做参数的构造函数之外,还有一个拿OutputStream做参数的构造函数。但是PrintWriter格式上并不比PrintStream的更好;它们的接口实际上是完全相同的。
PrintWriter的构造函数里还有一个可选的,能自动地进行清空操作的选项。如果你设了这个标记,那么每次println( )之后,它都会自动清空。
没变过的类
Java从1.0升到1.1时,有几个类没有变过:
在Java 1.1 中无相对应的类的 Java 1.0 的类
|
DataOutputStream
|
File
|
RandomAccessFile
|
SequenceInputStream
|
特别是DataOutputStream,用法都一点没变,所以你就可以用InputStream和OutputStream来读写可以传输的数据了。
自成一派: RandomAccessFile
RandomAccessFile是用来访问那些保存数据记录的文件的,这样你就可以用seek( )方法来访问记录,并进行读写了。这些记录的大小不必相同;但是其大小和位置必须是可知的。
首先,你可能会不太相信,RandomAccessFile竟然会是不属于InputStream和OutputStream类系的。实际上,除了实现DataInput和DataOutput接口之外(DataInputStream和DataOutputStream也实现了这两个接口),它和这两个类系毫不相干,甚至都没有用InputStream和OutputStream已经准备好的功能;它是一个完全独立的类,所有方法(绝大多数都只属于它自己)都是从零开始写的。这可能是因为RandomAccessFile能在文件里面前后移动,所以它的行为与其它的I/O类有些根本性的不同。总而言之,它是一个直接继承Object的,独立的类。
基本上,RandomAccessFile的工作方式是,把DataInputStream和DataOutputStream粘起来,再加上它自己的一些方法,比如定位用的getFilePointer(
),在文件里移动用的seek( ),以及判断文件大小的length(
)。此外,它的构造函数还要一个表示以只读方式("r"),还是以读写方式("rw")打开文件的参数 (和C的fopen( )一模一样)。它不支持只写文件,从这一点上看,假如RandomAccessFile继承了DataInputStream,它也许会干得更好。
只有RandomAccessFile才有seek方法,而这个方法也只适用于文件。BufferedInputStream有一个mark( )方法,你可以用它来设定标记(把结果保存在一个内部变量里),然后再调用reset( )返回这个位置,但是它的功能太弱了,而且也不怎么实用。
RandomAccessFile的绝大多数功能,如果不是全部的话,已经被JDK 1.4的nio的"内存映射文件(memory-mapped files)"给取代了。下面我们会讲到这部分内容的。
常见的I/O流的使用方法
虽然I/O流的组合方式有很多种,但最常用的也就那么几种。下
输入流
第一到第四部分演示了如何创建和使用InputStream。第四部分还简单地演示了一下OutputStream的用法。
1. 对输入文件作缓冲
要想打开打开文件读取字符,你得先用String或File对象创建一个FileInputReader。为了提高速度,你应该对这个文件作缓冲,因此你得把FileInputReader的reference交给BufferedReader。由于BufferedReader也提供了readLine( )方法,因此它就成为你最终要使用的那个对象,而它的接口也成为你使用的接口了。当你读到了文件的末尾时,readLine( )会返回一个null,于是就退出while循环了。
最后,用close( )来关闭文件。单从技术角度上说,程序退出的时候(不管有没有垃圾要回收)都应该调用finalize( ),而finalize(
)又会调用close( )。不过各种JVM的实现并不一致,所以最好还是明确地调用close( )。
System.in是一个InputStream,而BufferedReader需要一个Reader作参数,所以要先通过InputStreamReader来转转手。
2. 读取内存
(StringReader的)read( )方法会把读出来的byte当作int,所以要想正常打印的话,你得先把它们转换成char。
3. 读取格式化的内存
要想读取"格式化"的数据,你就得用DataInputStream了,它是一个面向byte的I/O类 (不是面向char的),因此你只能从头到底一直用InputStream了。当然你可以把所有东西(比方说文件) 都当成byte,然后用InputStream读出来,但这里是String。要想把String变成成byte数组,可以用String的getBytes( )方法,而ByteArrayInputStream是可以处理byte数组的。到了这一步,你就不用担心没有合适的InputStream来创建DataInputStream了。
如果你是用readByte( )逐字节地读取DataInputStream的话,那么无论byte的值是多少,都是合法的,所以你无法根据返回值来判断输入是否已经结束了。你只能用available(
)来判断还有多少字符。
注意,available( )的工作方式会随读取介质的不同而不同;严格地讲,它的意思是"可以不被阻塞地读取的字节的数目。"对文件来说,它就是整个文件,但如果是其它流,情况就不一定了,所以用之前要多留一个心眼。
你也可以像这样,用异常来检查输入是不是完了。但不管怎么说,把异常当成控制流程来用总是对这种功能的滥用。
4. 读取文件
(试试把BufferedWriter去掉,你就能看到它对性能的影响了—— 缓冲能大幅提高I/O的性能)。
LineNumberInputStream这是一个傻乎乎的,没什么用的类
输入流用完之后,readLine( )会返回null。如果写文件的时候不调用close( ),它是不会去清空缓冲区的,这样就有可能会落下一些东西了。
输出流
根据写数据的方式不同,OutputStream主要分成两类;一类是写给人看的,一类是供DataInputStream用的。虽然RandomAccessFile的数据格式同DataInputStream和DataOutputStream的相同,但它不属于OutputStream的。
5. 存储和恢复数据
PrintWriter会对数据进行格式化,这样人就能读懂了。但是如果数据输出之后,还要恢复出来供其它流用,那你就必须用DataOutputStream来写数据,再用DataInputStream来读数据了。当然,它们可以是任何流,不过我们这里用的是一个经缓冲的文件。DataOutputStream和DataInputStream是面向byte的,因此这些流必须都是InputStream和OutputStream。
如果数据是用DataOutputStream写的,那么不管在哪个平台上,DataInputStream都能准确地把它还原出来。这一点真是太有用了,因为没人知道谁在为平台专属的数据操心。如果你在两个平台上都用Java,那这个问题就根本不存在了 。
用DataOutputStream写String的时候,要想确保将来能用DataInputStream恢复出来,唯一的办法就是使用UTF-8编码,也就是像例程第5部分那样,用writeUTF( )和readUTF( )。UTF-8是Unicode的一种变形。Unicode用两个字节来表示一个字符。但是,如果你处理的全部,或主要是ASCII字符(只有7位),那么无论从存储空间还是从带宽上看,就都显得太浪费了,所以UTF-8 用一个字节表示ASCII字符,用两或三个字节表示非ASCII的字符。此外,字符串的长度信息存在(字符串)的头两个字节里。writeUTF(
)和readUTF( )用的是Java自己的UTF-8版本,所以如果你要用一个Java程序读取writeUTF( )写的字符串的话,就必须进行一些特殊处理了。
有了writeUTF( )和readUTF( ),你就能放心地把String和其它数据混在一起交给DataOutputStream了,因为你知道String是以Unicode的形式存储的,而且可以很方便地用DataOutputStream恢复出来。
writeDouble( )会往流里写double,而它"影子"readDouble( )则负责把它恢复出来(其它数据也有类似的读写方法)。但是要想让读取方法能正常工作,你就必须知道流的各个位置上都放了些什么数据。因为你完全可以把double读成byte,char,或其它什么东西。所以要么以固定的格式写文件,要么在文件里提供额外的解释信息,然后一边读数据一边找数据。先提一下,对于复杂数据的存储和恢复,对象的序列化可能会比较简单。
6. 读写随机文件
正如我们前面所讲的,如果不算它实现了DataInput和DataOutput接口,RandomAccessFile几乎是完全独立于其它I/O类库之外的,所以它不能与InputStream和OutputStream合起来用。虽然把ByteArrayInputStream当作"随机存取的元素(random-access element)"是一件很合情合理的事,但你只能用RandomAccessFile来打开文件。而且,你只能假定RandomAccessFile已经做过缓冲了,因为即便没做你也无能为力。
构造函数的第二个参数的意思是:是以只读("r") 还是读写("rw")方式打开RandomAccessFile。
RandomAccessFile的用法就像是DataInputStream和DataOutputStream的结合(因为它们的接口是等效的)。此外,你还能用seek( )在文件里上下移动,并进行修改。
随着JDK 1.4的new I/O的问世,你该考虑一下是不是用"内存映射文件(memory-mapped file)"来代替RandomAccessFile了。
管道流
这一章只会大致地提一下PipedInputStream,PipedOutputStream,PipedReader和PipedWriter。这并不是说它们不重要,只是因为管道流是用于线程间的通信的,所以除非你已经理解了多线程,否则是不会理解它的价值的。我们会在第13章用一个例子来讲解这个问题。
读写文件的实用程序
把文件读进内存,改完,再写文件。这是再普通不过的编程任务了。但是Java的I/O就是有这种问题,即便是做这种常规操作,你也必须写一大串代码——根本就没有辅助函数。更糟的是,那些喧宾夺主的decorator会让你忘了该怎样打开文件。因此比较明智的做法还是自己写一个辅助类。下面就是这样一个类,它包含了一些"能让你将文本文件当作字符串来读写"的static方法。此外,你还可以创建一个"会把文件的内容逐行存入ArrayList的"TextFile类,(这样在处理文件的时候,就能使用ArrayList的功能了):
//: com:bruceeckel:util:TextFile.java
// Static functions for reading and
writing text files as
// a single string, and treating a file
as an ArrayList.
// {Clean: test.txt test2.txt}
package com.bruceeckel.util;
import java.io.*;
import java.util.*;
public class TextFile extends
ArrayList {
// Tools to read and write files as single strings:
public static
String
read(String
fileName) throws IOException {
StringBuffer
sb = new StringBuffer();
BufferedReader in =
new BufferedReader(new
FileReader(fileName));
String s;
while((s = in.readLine()) != null) {
sb.append(s);
sb.append("\n");
}
in.close();
return sb.toString();
}
public static void
write(String
fileName, String text) throws IOException {
PrintWriter
out = new PrintWriter(
new BufferedWriter(new
FileWriter(fileName)));
out.print(text);
out.close();
}
public TextFile(String fileName) throws IOException {
super(Arrays.asList(read(fileName).split("\n")));
}
public void
write(String fileName) throws IOException {
PrintWriter
out = new PrintWriter(
new BufferedWriter(new
FileWriter(fileName)));
for(int i = 0; i
< size(); i++)
out.println(get(i));
out.close();
}
// Simple test:
public static void main(String[] args) throws
Exception {
String file =
read("TextFile.java");
write("test.txt", file);
TextFile text
= new TextFile("test.txt");
text.write("test2.txt");
}
} ///:~
|
所有这些方法都会直接往外面抛IOException。由于一行读出来之后,后面的换行符就没了,因此read( )会在每行的后面再加一个换行符,然后接到StringBuffer的后面(出于效率考虑)。最后它会返回一个"含有整个文件的内容"的String。write(
)的任务是打开文件,然后往里面写东西。任务完成之后要记着把文件给close( )了。
(TextFile)的构造函数用read( )方法将文件转化成String,然后用String.split( )和换行符转换成数组(如果你要经常使用这个类,或许应该重写一遍构造函数以提高性能)。此外,由于没有相应的"join"方法,非static的write( )方法只能手动地逐行打印文件。
为了确保它能正常工作,main( )作了一个基本测试。虽然它只是个小程序,但到后面你就会发觉,它却能帮你节省很多时间,同时让生活变得轻松一点。
标准I/O
"标准I/O"是Unix的概念,它的意思是,一个程序只使用一个信息流 (这种设计思想也以某种形式体现在Windows及其它很多操作系统上)。所有输入都是从"标准输入"进来的,输出都从"标准输出"出去,错误消息都送到"标准错误"里。标准I/O的优点是,它可以很容易地把程序串连起来,并且把一个程序的输出当作另一个程序的输入。这是一种非常强大的功能。
读取标准输入
Java遵循标准I/O的模型,提供了Syetem.in,System.out,以及System.err。本书一直都在用System.out往标准输出上写,而它(System.out)是一个已经预先处理过的,被包装成PrintStream的对象。和System.out一样,System.err也是一个PrintStream,但是System.in就不对了,它是一个未经处理的InputStream。也就是说,虽然你可以直接往System.out和System.err上写,但是要想读System.in的话,就必须先做处理了。
通常情况下,你会用readLine( )一行一行地读取输入,因此要把System.in包装成BufferedReader。但在这之前还得先用InputSteamReader把System.in转换成Reader。
将System.out转换成PrintWriter
System.out是PrintStream,也就是说它是OutputStream。不过PrintWriter有一个能将OutputStream改造成PrintWriter的构造函数。有了这个构造函数,你就可以随时将System.out转化成PrintWriter了:
为了启动自动清空缓冲区的功能,一定要使用双参数版的构造函数,并且把第二个参数设成true。这点非常重要,否则就有可能会看不到输出了。
标准I/O的重定向
Java的System类还提供了几个能让你重定向标准输入,标准输出和标准错误的静态方法:
setIn(InputStream)
setOut(PrintStream)
setErr(PrintStream)
如果程序在短时间内输出了大量的信息,使得翻屏的速度非常快,以致于你都没法读了,这时对输出进行重定向就会显得非常有用了
。对于那些要重复测试用户输入的命令行程序来说,对输入进行重定向也是非常重要的。
I/O重定向处理的不是character流,而是byte流,因此不能用Reader和Writer,要用InputStream和OutputStream。
New I/O
Java 1.4的java.nio.* 引入了一个"新"的I/O类库,这么做只有一个目的,就是速度。实际上为了充分利用它的性能优势,"旧"I/O类库也已经用nio重写了一遍,所以即便你没用nio写代码,也能从中受益。文件I/O和网络I/O的性能都有提升。
性能的提高源于它用了一种更贴近操作系统的结构:channel和buffer。你可以把它想像成一个煤矿;channel是埋着的煤炭资源(数据)的矿井,而buffer就是开进矿井的矿车。矿车开出矿井的时候装着煤,这时你就可以开始卸煤了。也就是说,你不是在同channel打交道了;你是在同buffer打交道。你把它送进channel,而channel要么是从buffer往下卸数据,要么把数据装到buffer里面。
ByteBuffer,也就是存储byte数据的buffer,是唯一一个能直接同channel打交道的buffer。java.nio.ByteBuffer是一个相当底层的类:创建对象的时候,你可以告诉它该分配多少空间;存储和提取数据的时候,你也可以选择是以未经处理的byte形式,还是以primitive形式。但是它不能存取对象,连String都不行。由于要有效地映射到了绝大多数操作系统上,它只能做得这么底层。
"新"I/O修改了"旧"I/O的三个类,即FileInputStream,FileOutputStream,以及RandomAccessFile,以获取FileChannel,其中RandomAccessFile的读写都做了修改。注意,由于要保持nio的底层特性,这几个类都是作用于byte流的。Reader和Writer这种处理字符流的类是不能创建channel的,但是java.nio.channels.Channels类收录了一些能用Reader和Writer创建channel的实用工具。
不管是这三个流里的哪一个,getChannel( )都会返回一个FileChannel。channel是相当底层的:你能给它一个ByteBuffer让它读写,也可以锁住文件的某些区域,然后以独占的方式进行访问(后面要讲)。
当然,选一个put方法,把一个或多个byte,或者primitive值直接塞进ByteBuffer,也不失为一种做法。如果把一个已经拿到手的byte数组"包"到ByteBuffer,用这种方法,新创建的ByteBuffer是不会去拷贝底层的(byte)数组的,相反它直接用那个byte数组来当自己的存储空间。所以我们说ByteBuffer的"后台"是数组。
注意,FileChannel可以在文件里随意移动;这里我们把它移到文件的末尾,因此往里面写的东西会被接在文件的末尾。
如果ByteBuffer访问的是输入流,那你还必须用static的allocate( )方法明确地指定其大小。nio的目的是要能快速地移动大量数据,因此ByteBuffer的大小是至关重要的 (要想找出最佳的值,只能不断去试了)。
要想进一步提高速度,可以用allocateDirect( )来代替allocate( ),它返回的是一个与操作系统配合得更紧密的"direct" buffer。但是这种分配的开销也比较大,而且其实现方式会随操作系统的不同而不同。所以,又要说了,只有试了才知道。
read( )是告诉FileChannel往ByteBuffer里存数据。数据存完之后,就得调用buffer的flip( )了,叫它做好准备,我们要提数据了(是的,这看上去太原始,但是为了提高速度,只能做得这么底层)。此外,如果如果你还要用buffer读取更多的数据,那么每次read( )之前还得先clear( )。
FileChannel的transferTo( )和transFrom( )方法能让你把两个channel直接接起来:
这不是什么常用方法,但是应该了解一下。
转换数据
要想显示文件的内容,只能一个byte一个byte的把数据拉出来,再把它转换成char。假如你知道了有一个java.nio.CharBuffer的话,你就会觉得这种做法太原始了。CharBuffer的toString( )方法,能返回当前buffer的字符串。而且ByteBuffer的asCharBuffer( )能把ByteBuffer视作CharBuffer。那么我们为什么不用那个方法呢?不行:
要把buffer里面的普通的bytes转换成字符,或者是先编码再放进去(这样拿出来的时候就有意义了),或者是提出来之后再解码。这要用到java.nio.charset.Charset,它能用很多字符集进行编码:
好要想确定默认的字符集,可以用System.getProperty("file.encoding"),它会返回这个字符集的名字。
CharBuffer的toString( )会用零来表示剩下的字符。
提取primitive
虽然ByteBuffer保存的是byte,但它还收录了一些能提取这些byte所表示的primitive值的方法。下面我们来演示一下怎样用这些方法来读写各种primitive的值:
分配完ByteBuffer之后,我们马上就作了检查。这么做的目的是想看看,缓冲区分配的时候会不会自动清零——结果不出所料。我们检查了所有1024个值(一直到这个buffer的limit(
)),结果都是零。
要想往ByteBuffer里插primitive,最简单的办法就是用asCharBuffer( ),asShortBuffer( )之类的方法来获取buffer的"view",然后用这个view的put( )方法往里面插值。其中唯一一个有点奇怪的地方就是ShortBuffer的put( ),它要求你做类型转换(注意这个转换会把值截短,因而会改变值)。其它view buffer的put( )方法都不要求作类型转换。
View buffers
"view buffer"能让你透过一个特殊视角,来观察其(即view buffer的)底层的ByteBuffer。换言之,就是把ByteBuffer里面的数据都视作某种primitive。ByteBuffer还是view背后真正存储数据的地方,所以对view的任何操作都会作用到ByteBuffer上。有了view,你就能很方便地把primitive插进ByteBuffer了。view还能让你从ByteBuffer里读取primitive,你既可以一个一个地读(就是ByteBuffer的方法),也可以以批处理的方式(读进一个数组)。
int或其它primitive通过view buffer装进ByteBuffer,等到ByteBuffer装满,你就可以直接把它写入channel了。你可以很方便地从channel里读数据,并且用view buffer把它们解释成primitive。
Endians
不同的计算机,其存储数据的字节顺序也可能会不同。"Big endian"的意思是,把最重要的字节放在最低位,而"little
endian"的意思是把最重要的字节放在最高位。对于像int或float这样要用一个以上的字节来存储的数据,你也许得考虑一下字节的顺序。ByteBuffer以 big endian的形式存储数据,此外网络也总是使用big endian的方式传输数据。不过你可以用order( )方法以及ByteOrder.BIG_ENDIAN或ByteOrder.LITTLE_ENDIAN参数,来修改ByteBuffer的endian-ness。
默认情况下ByteBuffer是以big endian的顺序排列的,而改成little endian之后,相邻位置上的byte互换了位置。
用buffer操控数据
如果你想把byte数组写进文件,你得先用ByteBuffer.wrap( )方法把这个byte数组wrap成buffer,再用getChannel( )在FileOutputStream上打开一个channel,然后才能用ByteBuffer把数据写入FileChannel。
注意,ByteBuffer是往channel里读写数据的唯一途径,而且你只能创建这一种primitive类型的buffer,其余的要用"as" 方法来获取。也就是说,你不能把primitive的buffer转换成ByteBuffer。不过你可以用view buffer往ByteBuffer里读写primitive数据,所以这实际上也不是什么大问题。
Buffer的细节
Buffer由数据及四个"指针(index)"组成,这些指针能帮你有效地访问和处理数据,它们分别是:mark,position,limit 以及capacity。Java提供了一些能设置和清除这些指针,并查询其值的方法。
capacity( )
|
返回buffer的capacity(容量)
|
clear( )
|
清空整个缓冲区,把position设为零,把limit设为capacity。用这个方法来"清除(overwrite)"已有的缓冲区。
|
flip( )
|
把limit设为position,把position设为零。当你将数据写入buffer,准备读取的时候,必须先调用这个方法。
|
limit( )
|
返回limit的值。
|
limit(int lim)
|
设置limit的值。
|
mark( )
|
把mark设为position。
|
position( )
|
返回position的值。
|
position(int pos)
|
设置position的值。
|
remaining( )
|
返回(limit - position)。
|
hasRemaining( )
|
如果position和limit之间还有数据,就返回true。
|
数据读写的时候会更新这些index以反映buffer的变化。
内存映射文件
内存映射文件(memory-mapped file)能让你创建和修改那些大到无法读入内存的文件。有了内存映射文件,你就可以认为文件已经全部读进了内存,然后把它当成一个非常大的数组来访问了。这种解决思路能大大简化修改文件的代码。
为了能以读写的方式打开文件,我们从RandomAccessFile入手。拿到channel之后,我们用map( )方法生成了一个MappedByteBuffer。这是一种特殊的"direct buffer"。注意,你必须指明,它是从文件的哪个位置开始映射的,映射的范围又有多大;也就是说,它还可以映射一个大文件的某个小片断。
MappedByteBuffer是ByteBuffer的派生类,因此它具备了ByteBuffer的所有方法。这里只简单地演示了一下put( )和get( )方法,除此之外,你还可以使用asCharBuffer( )之类的方法。
真正调入内存的只是其中的一小部分,其余部分则被放在交换文件上。这样你就可以很方便地修改超大型的文件了(最大可以到2 GB)。注意,Java是调用操作系统的"文件映射机制(file-mapping facility)"来提升性能的。
性能
上只有RandomAccessFile才能写映射文件。
文件锁
JDK 1.4的文件锁"file locking"允许你以文件为共享资源,对访问进行同步化处理(allows you to synchronize access to a file as a shared resource)。但是,竞争文件的两个线程必须属于两个不同的JVM,或者一个是Java线程,另一个是操作系统的本地线程。由于Java的文件锁是直接映射操作系统的锁机制的,因此其它进程也能看到文件锁。
要想获取整个文件的锁,可以用FileChannel的tryLock( )或lock( )方法。(SocketChannel,DatagramChannel,以及 ServerSocketChannel是不需要锁的,因为它们原本就是"进程实体(single-pricess entities)";一般来说,你是不会让两个进程去共享一个网络socket的。)tryLock( ) 是非阻塞的。它会试着去获取这个锁,但是如果得不到(其它进程已经以独占方式得到这个锁了),那它就直接返回。而lock( )是阻塞的。如果得不到锁,它会在一直处于阻塞状态,除非它得到了锁,或者你打断了调用它(即lock( )方法)的线程,或者关闭了它要lock( )的channel,否则它是不会返回的。最后用FileLock.release( )释放锁。
还可以像这样锁住文件的某一部分
tryLock(long position, long size, boolean
shared)
或者
lock(long position, long size, boolean
shared)
这个方法能锁住文件的某个区域(size - position)。其中第三个参数表示锁能不能共享。
虽然在修改文件的过程中,无参数的lock( )和tryLock( )方法的锁定范围会随文件大小的变化,带参数的方法却不行。如果你锁住了position到position+size这段范围,而文件的长度又增加了,那么position+size后面是不加锁的。而无参数的lock方法则会锁定整个文件,不管它变不变长。
锁是独占的还是共享的,这要由操作系统来决定。如果操作系统不支持共享锁,而程序又申请了一个,那么它会返回一个独占锁。你可以用FileLock.isShared( )来查询锁的类型(共享还是独占)。
锁住映射文件的某一部分
正如我们前面所讲的,文件映射常用于大型文件。对大型文件,你通常要做的只是锁住其中的片断,这样其他进程就能修改其余部分了。这就是多用户系统,比方说数据库,的工作原理。
JVM退出,或者channel关闭的时候会自动释放这些锁,但是你也可以用FileLock的release( )方法,明确地释放锁,就像例程那样。
压缩
Java I/O类库还收录了一些能读写压缩格式流的类。要想提供压缩功能,只要把它们包在已有的I/O类的外面就行了。
这些类不是Reader和Writer,而是InputStream和OutStreamput的派生类。这是因为压缩算法是针对byte而不是字符的。所以,有时你不得不将这两种流合起来用。(记住,用InputStreamReader和OutputStreamWriter能很方便地将一种流转换成另一种流。)
压缩类
|
功能
|
CheckedInputStream
|
GetCheckSum( )会返回InputStream的checksum(不仅仅限于解压缩用)。
|
CheckedOutputStream
|
GetCheckSum( )会返回OutputStream的checksum(不仅仅限于压缩用)。
|
DeflaterOutputStream
|
压缩类的基类。
|
ZipOutputStream
|
DeflaterOutputStream的一个子类,把数据压缩成Zip文件格式。
|
GZIPOutputStream
|
DeflaterOutputStream的一个子类,把数据压缩成GZip文件格式。
|
InflaterInputStream
|
解压缩类的基类。
|
ZipInputStream
|
InflaterInputStream的一个子类,能解压缩Zip格式的数据。
|
GZIPInputStream
|
InflaterInputStream的一个子类,能解压缩Zip格式的数据。
|
虽然有很多种压缩算法,但Zip和GZIP可能是最常用的。因此你可以用很多工具来读写这些压缩数据。
用GZIP进行简单的压缩
GZIP的接口比较简单,因此如果你只有一个流要压缩的话(不是针对装在容器里的,差别比较大的数据的),用它会比较合适。
压缩类的用法非常简单;只要用GZIPOutputStream 或ZipOutputStream把输出流包起来,再用GZIPInputStream 或ZipInputStream把输入流包起来就行了。剩下的都是些普通的I/O操作。这段程序演示了怎样将char流与byte流结合起来使用;in是一个Reader,而GZIPOutputStream的构造函数不接受Writer,它只认OutputStream。此外解压缩的时候,还要把GZIPInputStream转换成Reader。
用Zip存储多个文件
Zip类库的功能就复杂多了。它能很方便的存储多个文件,甚至还有一个专门用来简化Zip文件读取的类。这些类库都使用标准的Zip格式,因此能同Internet上下载下来的工具配合得天衣无缝。Checksum类来计算和校验文件的checksum。这里有两种Checksum:Adler32(更快一些)和CRC32(慢一点但比较准确)。
要想把文件加入压缩包,你必须将ZipEntry对象传给putNextEntry( )。ZipEntry是一个接口很复杂的对象,它能让你设置和读取Zip文件里的某条记录的信息,这些信息包括:文件名,压缩前和压缩后的大小,日期,CRC校验码,附加字段,注释,压缩方法,是否是目录。虽然标准的Zip格式是支持口令的,但是Java的Zip类库却不支持。而且ZipEntry却只提供了CRC的接口,而CheckedInputStream和CheckedOutputStream却支持Adler32和CRC32两种校验码。虽然这是底层的Zip格式的限制,但却妨碍了你使用更快的Adler32了。
要想提取文件,可以用ZipInputStream的getNextEntry( )方法。只要压缩包里还有ZipEntry,它就会把它提取出来。此外还有一个更简洁的办法,你可以用ZipFile对象去读文件。ZipFile有一个entries( )方法,它可以返回ZipEntries的Enumeration。
要想读取校验码,必须先获取Checksum对象。我们这里用的是CheckedOutputStream和CheckedInputStream,不过你也可以使用Checksum。
Zip类里还有一个让人莫名其妙的setComment( )方法。写文件的时候,你可以加注释,但是读文件的时候,ZipInputSream却不提供接口。看来它的注释功能完全是针对记录的,是用ZipEntry实现的。
当然,GZIP和Zip不光能用来压缩文件——它还能压缩任何东西,包括要通过网络传输的数据。
Java ARchives (JARs)
Zip格式还被用于JAR(Java ARchive "Java卷宗")。JAR和Zip一样,也是一种把一组文件压缩成一个单独文件的方法。但是和Java的其它东西一样,JAR也是跨平台的,所以你不用担心平台问题。你甚至可以把音频和图象文件,连同class文件一起放在JAR里面。
在Internet环境下,JAR会显得特别有用。在JAR问世之前,为了下载一个applet,浏览器得反复地请求Web服务器,更何况这些文件还都没压缩过。有了JAR之后,一个applet只要请求一次就够了,而且经过压缩,传输的速度也加快了。此外,你还可以逐项签署JAR里的文件,这样就能确保安全性了。
一个JAR"卷宗"只有一个文件,"卷宗"包含两个文件,一个是Zip文件,另一个是描述Zip文件所包含的文件的"manifest(清单)"。(你可以自己动手写一个manifest;否则jar程序会为你创建一个。)
Sun发布的JDK里包括了jar,它能根据你的命令自动生成JAR文件。具体的命令如下:
jar [options] destination [manifest] inputfile(s)
options是一组字母(没有减号,也没有其它标点符号)。Unix/Linux用户会发现它同tar的选项很相似。它们是:
c
|
创建一个新的或空的"卷宗"。
|
t
|
列出内容列表。
|
x
|
提取所有文件。
|
x file
|
提取具体的文件。
|
f
|
意思是:"我要告诉你这个文件的名字了。"如果你不用这个选项,jar会认为应该从标准输入读取输入,或者,如果是创建文件的话,打印到标准输出上。
|
m
|
表示第一个参数是用户自定义的manifest文件。
|
v
|
生成详细(verbose)的说明。
|
0
|
仅存储文件;不进行压缩(用来创建放在CLASSPATH里的JAR文件)。
|
M
|
不要自动创建manifest文件。
|
如果创建JAR"卷宗"的时候把子目录也放了进去,那么这个子目录,连同子目录的子目录,都会被自动加进去。路径信息也同时保留。
下面是一些经典的jar命令:
jar cf myJarFile.jar *.class
它会创建了一个名为myJarFile.jar的JAR"卷宗"。这个卷宗会包括当前目录下的所有class文件,并且自动生成manifest文件。
jar cmf myJarFile.jar myManifestFile.mf *.class
跟上面那个命令差不多,只是加了个用户自定义的,名为myManifestFile.mf的manifest文件。
jar tf myJarFile.jar
返回myJarFile.jar所含文件的列表。
jar tvf myJarFile.jar
加"verbose(详细)"标志,给出更详细的,关于myJarFile.jar当中的文件的信息。
jar cvf myApp.jar audio classes image
假设audio,classes,image都是子目录。这个命令会把这些子目录全都收进myApp.jar。"verbose"标志则会命令jar程序在运行的时候生成更详细的信息。
如果JAR"卷宗"是用0(零)选项创建的,那么它就能被列入CLASSPATH了:
CLASSPATH="lib1.jar;lib2.jar;"
于是Java就能在lib1.jar和lib2.jar里找class文件了。
jar还是不如zip有用。比方说,你不能往已经做好的JAR"卷宗"里添加新文件或修改文件;你只能再创建一个新的。还有,你不能在往JAR"卷宗"里移文件的同时把原来的文件给删了。不过JAR格式是跨平台的,无论JAR"卷宗"是在哪个平台上创建的,jar程序都能将它读出来(zip格式有时就会有问题了)。
对象的序列化
Java的"对象序列化"能让你将一个实现了Serializable接口的对象转换成一组byte,这样日后要用这个对象时候,你就能把这些byte数据恢复出来,并据此重新构建那个对象了。这一点甚至在跨网络的环境下也是如此,这就意味着序列化机制能自动补偿操作系统方面的差异。也就是说,你可以在Windows机器上创键一个对象,序列化之后,再通过网络传到Unix机器上,然后在那里进行重建。你不用担心在不同的平台上数据是怎样表示的,byte顺序怎样,或者别的什么细节。
对象序列化本身就非常有趣,因为它能让你实现"轻量级的persistence(lightweight
persistence)"。所谓persistence是指,对象的生命周期不是由程序是否运行决定的;在程序的两次调用之间对象仍然还活着。通过"将做过序列化处理的对象写入磁盘,等到程序再次运行的时候再把它读出来",你可以达到persistence的效果。之所以说"轻量级",是因为你不能用像"persistent"这样的关键词来直接定义一个对象,然后让系统去处理所有细节(虽然将来有可能会这样)。相反,你必须明确地进行序列化(serialize)和解序列化(deserialize)。如果你需要更为正式的persistence功能,可以考虑Java Data Object( 简称是JDO)或Hibernate之类的工具(http://hibernate.sourceforge.net)。至于具体的内容,请到www.BruceEckel.com 去下载Thinking in
Enterprise Java。
之所以要在语言里加入对象序列化是因为要用它来实现两个重要的功能。Java的远程方法调用(Remote Method Invocation简称RMI)能让你像调用自己机器上的对象那样去调用其它机器上的对象。当你向远程对象传递消息的时候,就需通过对象序列化来传送参数和返回值了。
对JavaBean来说,对象序列化也是必不可少的。Bean的状态信息通常是在设计时配置的。这些状态信息必须保存起来,供程序启动的时候用;对象序列化就负责这个工作。
序列化一个对象还是比较简单的,只要让它实现Serializable接口就行了(这是一个"标记接口(tagging interface)",没有任何方法)。但是,当语言引入序列化概念之后,它的很多标准类库的类,包括primitive的wrapper类,所有的容器类,以及别的很多类,都会相应地发生改变。甚至连Class对象都会被序列化。
要想序列化对象,你必须先创建一个OutputStream,然后把它嵌进ObjectOutputStream。这时,你就能用writeObject( )方法把对象写入OutputStream了。读的时候,你得把InputStream嵌到ObjectInputStream里面,然后再调用readObject( )方法。不过这样读出来的,只是一个Object的reference,因此在用之前,还得先下传。
对象序列化最聪明的一点是,它不仅能保存对象的副本,而且还会跟着对象里面的reference,把它所引用的对象也保存起来,然后再继续跟踪那些对象的reference,以此类推。这种情形常被称为"单个对象所联结的'对象网'"。这个机制所涵盖的范围不仅包括对象的成员数据,而且还包含数组里面的reference。如果你要自己实现对象序列化的话,那么编写跟踪这些链接的程序将会是一件非常痛苦的任务。但是,Java的对象序列化就能精确无误地做到这一点,毫无疑问,它的遍历算法是做过优化的。
为了让事情变得更有趣一点,我们用随机数来初始化Worm的Data数组的对象。(这样你就不用怀疑编译器会保存什么元信息了(meta-information)。)Worm的每一段都有一个char标记,这是递归生成Worm链表时自动产生的。创建Worm时,你得告诉它有多长。然后它会用这个长度减一去调用构造函数,并且把生成的Worm对象传给next。最后一个next的reference是null,表示Worm结束了。
对象序列化是面向byte的,因而要用InputStream和OutputStream。
查找class
控制序列化
正如你所看到的,默认的序列化机制非常易用。但是如果你有特殊的要求,那又该怎么办呢?或许出于某些特殊的安全考虑,你不想序列化对象的某些部分,或许恢复对象的时候应该创建一个新的子对象,直接恢复出来的那个对象是毫无意义的。
你可以让对象去实现Externalizable而不是Serializable接口,并以此来控制序列化的过程。Externalizable接口继承了Serializable接口,此外它还添加了两个新方法,writeExternal( )和readExternal( )。这两个方法会在序列化和恢复的过程中被调用,这样你就能进行特殊的操作了。
恢复Serializable对象不调用任何构造函数,完全根据存储的数据来重建对象。对于Externalizable对象,readExternal( )要在默认的构造行为会发生之后(包括在定义数据成员时进行的初始化)才启动。只有知道这一切——特别是默认的构造过程肯定会发生——你才能正确地使用Externalizable对象。
如果对象是Externalizable的,为了能正确地存取其父类的组件,你还得调用其父类的writeExternal(
)和readExternal( )。
所以要想让程序正常运行,不但要在writeExternal( )的时候把重要的数据保存起来(默认情况下,Externalizable对象不会保存任何成员对象),还得在readExternal( )的时候把它们恢复出来。这一点初看起来会让人觉得很困惑,照理说Externalizable对象的默认构造行为应该能自动地存取一些东西,但事实并非如此。
Transient关键词
控制序列化的时候还可能会碰到,要禁止Java的序列化机制自动存取某个子对象的情况。这种需求是很常见的,你肯定不会希望把口令之类敏感信息也序列化了。因为一旦序列化了,别人就能通过分析文件和截获网络传输来进行破解,这样即便访问权限是private的也保不住了。
要想禁止敏感信息的序列化,前面讲的Externalizable是一法。这样一来,它就不会再自动地序列化任何东西了,而你也可以在writeExternal( )方法里把要序列化的东西明明白白地列出来了。
但是Serializable对象的序列化是自动的。要想控制它的序列化,你就得使用transient关键词了。它的作用是,在字段级别上关闭序列化功能,也就是说"你别管这个成员的存储和恢复了——我自己来。"
由于默认情况下,Externalizable对象不保存任何字段,因此transient只能用于Serializable对象。
Externalizable的替代方案
如果你不喜欢Externalizable,那么还有一条路可走。你可以选择Serializable接口,然后再加入(注意,我没说"覆写"或"实现",我说的是"加入")序列化和恢复的时候会自动调用的writeObject( )和readObject( )方法。也就是说,如果你写了这两个方法,Java就会避开默认的序列化机制而去调用这两个方法了。
这两个方法的特征签名如下:
private void writeObject(ObjectOutputStream
stream)
throws IOException;
private void readObject(ObjectInputStream stream)
throws IOException, ClassNotFoundException
从设计的角度看,这里的事情真是有些不可理喻。首先,你的第一反映应该是,这两个方法不是它们基类或Serializable接口的方法,因此它们应该是这个类自己的方法。但是请注意,它们都是private的,也就是说只有这个类的其它成员才能调用。可是你又不会真的用这个类的其它方法去调用它们,相反是ObjectOutputStream和ObjectInputStream的writeObject( )和readObject( )在调用它们。(我已经尽量克制自己,不要出口伤人了。但是为什么要在这种地方使用相同名字呢。总之一词:搞不懂)也许你会问,ObjectOutputStream和ObjectInputStream是怎样访问这两个private方法的。我们也只能暂且认为这属于序列化的魔法。
不管怎么说,在interface里定义的都是public的。所以,如果writeObject( )和readObject( )必须是private的,那它们就不能是interface的。但是,你又必须严格遵守这两个方法的特征签名,因此实际上同实现interface没什么两样。
或许是这样,当你调用ObjectOutputStream.writeObject( )的时候,它会审察你传过去的Serializable对象(毫无疑问,是用reflection机制),看看它是不是实现了writeObject( )。如果是,它就会跳过普通的序列化过程而去调用writeObject(
)了。readobject( )也一样。
此外,还有一个窍门。你还可以选择是不是在writeObject( )里面用defaultWriteObject( )来进行默认的writeObject( )。同样,也可以在readObject( )里面调用defaultReadObject( )。
defaultWriteObject( )会存储非transient的数据;而transient的,要明明白白地列出来。
如果你决定用默认的序列化机制来存储非transient的数据,你就得在writeObject( )里面调用defaultWriteObject( ),而且得第一个做。恢复的时候,也要在readObject(
)的开头部分调用defaultReadObject( )。这都是些很莫名其妙的方法。比方说,你调用defaultWriteObject( )的时候是不带参数的,但是ObjectOutputStream调用这个方法的时候居然会拿到那个对象的reference,然后把非transient的数据一一写进去。活见了鬼!
存储和恢复transient数据的程序,还算比较顺眼。不过想一想它都做了些什么。main( )创建了一个SerialCtl对象,然后用序列化把它写进了ObjectOutputStream。(注意这里用缓冲区来代替文件——对ObjectOutputStream来说,没什么差别。)下面就是进行序列化的这行代码:
o.writeObject(sc);
writeObject( )必须检查sc才能知道它是不是有writeObject( )方法。(既不是查接口——肯定不会有的——也不是看class类型,必须实实在在地用reflection找。) 如果有,那就用它了。readObject( )也差不多。或许这是解决这个问题的唯一可行的方案,但终究还是怪异。
版本控制
或许你还要控制serializable类的版本(比方说,原先那个类的对象还在数据库里)。虽然Java有这个功能,但是不到万不得已,最好别去用。
使用persistence
用
序列化技术来存储程序的状态,是一项很具吸引力的技术。这样一来,你就能很方便地将程序恢复到它当前的运行状态了。但是在这么做之前,你必须先解决一些问
题。假如你要序列化两个对象,而它们又同时引用了第三个对象,那么你该怎么序列化?当你把这两个对象恢复出来的时候,是不是应该只有一个"第三个对象"呢?如果这两个对象被写入了不同的文件,而程序又不是在一个地方恢复它们,那又该怎么办呢?
用对象序列化来"deep copy",也就是把Seriablizable的对象写入byte数组。(deep copy的意思是,复制的整个对象网,而不仅仅是"基本对象及其reference"。) 。
对象恢复出来之后地址会不同。
只要你写的是同一个流,恢复出来的就是同一张网,不会有意料之外的的重复。当然,你可以在序列化的过程中修改对象的状态,但这是在自找麻烦;序列化的时候,不论对象处于哪种状态(或者引用了什么对象),都会被一五一十地写进去。
要想保存系统的状态,最安全的办法是把序列化做成"原子操作(atomic operation,即不可分隔的操作)" 。如果你一边在做序列化,一边在做其它的事情,那么就有可能无法安全地恢复系统状态了。因此,你应该把所有描述系统状态的对象都放进一个容器,然后直接对容器作序列化。这样,你就可以只调用一个方法就恢复整个系统的状态了。
Class也是Serializable的,所以要想存储static数据,只要把Class对象也序列化了就行。不管怎么说,这个思路还是很合乎逻辑的。
static的方法不是动态绑定的,因此它们都是普通方法
尽管Class类是Serializable的,但是它的工作方式却与你的预期有些出入。所以如果你要序列化static数据,就必须亲自动手。
由于序列化的时候会把private数据也写进去,因此还得考虑安全问题。那些涉及安全问题的字段应该被标为transient的。但是这样一来,你就得设计一个安全的方案来存储这些信息了,这样恢复的时候,就能重新设置private变量的值了。
Preferences
JDK 1.4所引入的Preferences API能自动存储和恢复信息,因此要比对象的序列化更接近persistence。但是,它只能存取很少几种数据——primitive和String,而且每个String的长度都不能超过8K(不算很小,但也不能指望用它来做什么很重要的事。)正如它的名字所显示的,Preferences API是用来存储和恢复用户的使用习惯及程序配置的。
Preference是一组存储在"由节点所组成的层次体系(a hierarchy of nodes)"里的键值集(很像Map)。虽然你可以利用这个"层次体系"来表示非常复杂的结构,但通常我们只会根据类的名字创建一个节点,然后把与这个类相关的信息写进去。
可以用的是userNodeForPackage( ),不过你也可以用systemNodeForPackage(
);实际上你可以凭自己的喜好随便选一个,只是要记住"user"指的是单个用户的preference,而"system"指整个系统的共用配置。
创建完节点之后,就可以存取数据了。这里,我们往节点里装了各种类型的数据,然后用keys( )方法取出键。(keys( )方法的)返回结果是一个String[],如果你已经习惯了Collections的keys( )的话,可能会觉得有些意外了。接下来,我们把它转换成了List,这样就能用Iterator来打印键和值。注意get( )的第二个参数。如果这个键没有相应的值,那么这个参数就是默认的返回值。如果用Iterator,那没问题,这些记录肯定都是已经存在的,所以用null作默认值还是很安全的。
其实Preferences API是借用操作系统的资源来实现这项功能的。对于Windows,它就放在注册表里(因为注册表本身就是键值对节点的层次体系)。不过这个API的意义在于,信息已经被存起来了,所以你就不用操心它在各种系统里是如何工作的了。
Preferences API的功能还远不止这些。要想了解更多的细节,请参阅JDK的文档,还是比较容易理解的。
正则表达式
作为本章的结尾,我们来看一看正则表达式(regular expression)。正则表达式是JDK 1.4的新功能,但是对sed和awk这样的Unix的标准实用工具,以及Python,Perl之类的语言来讲,它早就已经成为其不可或缺的组成部分了(有人甚至认为,它还是Perl能大获成功的最主要的原因)。单从技术角度来讲,正则表达式只是一种处理字符串的工具(过去Java这个任务是交由String,StringBuffer以及StringTokenizer处理的),但是它常常和I/O一起使用,所以放到这里来讲也不算太离题吧。[66]
正
则表达式是一种功能强大但又非常灵活的文本处理工具。它能让你用编程的方式来描述复杂的文本模式,然后在字符串里把它找出来。一旦你找到了这种模式,你就
能随心所欲地处理这些文本了。虽然初看起来正则表达式的语法有点让人望而生畏,但它提供了一种精练的动态语言,使我们能用一种通用的方式来解决各种字符串
的问题,包括匹配,选择,编辑以及校验。
创建正则表达式
你可以从比较简单的东西入手学习正则表达式。要想全面地掌握怎样构建正则表达式,可以去看JDK文档的java.util.regex的Pattern类的文档。
字符
|
B
|
字符B
|
\xhh
|
16进制值0xhh所表示的字符
|
\uhhhh
|
16进制值0xhhhh所表示的Unicode字符
|
\t
|
Tab
|
\n
|
换行符
|
\r
|
回车符
|
\f
|
换页符
|
\e
|
Escape
|
正则表达式的强大体现在它能定义字符集(character class)。下面是一些最常见的字符集及其定义的方式,此外还有一些预定义的字符集:
字符集
|
.
|
表示任意一个字符
|
[abc]
|
表示字符a,b,c中的任意一个(与a|b|c相同)
|
[^abc]
|
除a,b,c之外的任意一个字符(否定)
|
[a-zA-Z]
|
从a到z或A到Z当中的任意一个字符(范围)
|
[abc[hij]]
|
a,b,c,h,i,j中的任意一个字符(与a|b|c|h|i|j相同)(并集)
|
[a-z&&[hij]]
|
h,i,j中的一个(交集)
|
\s
|
空格字符(空格键, tab, 换行, 换页, 回车)
|
\S
|
非空格字符([^\s])
|
\d
|
一个数字,也就是[0-9]
|
\D
|
一个非数字的字符,也就是[^0-9]
|
\w
|
一个单词字符(word character),即[a-zA-Z_0-9]
|
\W
|
一个非单词的字符,[^\w]
|
如果你用过其它语言的正则表达式,那么你一眼就能看出反斜杠的与众不同。在其它语言里,"\\"的意思是"我只是要在正则表达式里插入一个反斜杠。没什么特别的意思。"但是在Java里,"\\"的意思是"我要插入一个正则表达式的反斜杠,所以跟在它后面的那个字符的意思就变了。"举例来说,如果你想表示一个或更多的"单词字符",那么这个正则表达式就应该是"\\w+"。如果你要插入一个反斜杠,那就得用"\\\\"。不过像换行,跳格之类的还是只用一根反斜杠:"\n\t"。
这里只给你讲一个例子;你应该JDK文档的java.util.regex.Pattern加到收藏夹里,这样就能很容易地找到各种正则表达式的模式了。
逻辑运算符
|
XY
|
X 后面跟着 Y
|
X|Y
|
X或Y
|
(X)
|
一个"要匹配的组(capturing
group)". 以后可以用\i来表示第i个被匹配的组。
|
边界匹配符
|
^
|
一行的开始
|
$
|
一行的结尾
|
\b
|
一个单词的边界
|
\B
|
一个非单词的边界
|
\G
|
前一个匹配的结束
|
数量表示符
"数量表示符(quantifier)"的作用是定义模式应该匹配多少个字符。
- Greedy(贪婪的): 除非另有表示,否则数量表示符都是greedy的。Greedy的表达式会一直匹配下去,直到匹配不下去为止。(如果你发现表达式匹配的结果与预期的不符),很有可能是因为,你以为表达式会只匹配前面几个字符,而实际上它是greedy的,因此会一直匹配下去。
- Reluctant(勉强的): 用问号表示,它会匹配最少的字符。也称为lazy,
minimal matching, non-greedy, 或ungreedy。
- Possessive(占有的): 目前只有Java支持(其它语言都不支持)。它更加先进,所以你可能还不太会用。用正则表达式匹配字符串的时候会产生很多中间状态,(一般的匹配引擎会保存这种中间状态,)这样匹配失败的时候就能原路返回了。占有型的表达式不保存这种中间状态,因此也就不会回头重来了。它能防止正则表达式的失控,同时也能提高运行的效率。
Greedy
|
Reluctant
|
Possessive
|
匹配
|
X?
|
X??
|
X?+
|
匹配一个或零个X
|
X*
|
X*?
|
X*+
|
匹配零或多个X
|
X+
|
X+?
|
X++
|
匹配一个或多个X
|
X{n}
|
X{n}?
|
X{n}+
|
匹配正好n个X
|
X{n,}
|
X{n,}?
|
X{n,}+
|
匹配至少n个X
|
X{n,m}
|
X{n,m}?
|
X{n,m}+
|
匹配至少n个,至多m个X
|
再提醒一下,要想让表达式照你的意思去运行,你应该用括号把'X'括起来。
CharSequence
JDK 1.4定义了一个新的接口,叫CharSequence。它提供了String和StringBuffer这两个类的字符序列的抽象:
interface CharSequence {
charAt(int i);
length();
subSequence(int start, int end);
toString();
}
为了实现这个新的CharSequence接口,String,StringBuffer以及CharBuffer都作了修改。很多正则表达式的操作都要拿CharSequence作参数。
Pattern和Matcher
Java的正则表达式是由java.util.regex的Pattern和Matcher类实现的。Pattern对象表示经编译的正则表达式。静态的compile( )方法负责将表示正则表达式的字符串编译成Pattern对象。只要给Pattern的matcher( )方法送一个字符串就能获取一个Matcher对象。此外,Pattern还有一个能快速判断能否在input里面找到regex的(注意,原文有误,漏了方法名)
static boolean matches( regex, input)
以及能返回String数组的split( )方法,它能用regex把字符串分割开来。
只要给Pattern.matcher( )方法传一个字符串就能获得Matcher对象了。接下来就能用Matcher的方法来查询匹配的结果了。
boolean matches()
boolean lookingAt()
boolean find()
boolean find(int start)
matches( )的前提是Pattern匹配整个字符串,而lookingAt( )的意思是Pattern匹配字符串的开头。
find( )
Matcher.find( )的功能是发现CharSequence里的,与pattern相匹配的多个字符序列。
Groups
Group是指里用括号括起来的,能被后面的表达式调用的正则表达式。Group 0 表示整个表达式,group 1表示第一个被括起来的group,以此类推。所以;
A(B(C))D
里面有三个group:group 0是ABCD, group 1是BC,group 2是C。
你可以用下述Matcher方法来使用group:
public int groupCount( )返回matcher对象中的group的数目。不包括group0。
public String group( ) 返回上次匹配操作(比方说find( ))的group 0(整个匹配)
public String group(int i)返回上次匹配操作的某个group。如果匹配成功,但是没能找到group,则返回null。
public int start(int group)返回上次匹配所找到的,group的开始位置。
public int end(int group)返回上次匹配所找到的,group的结束位置,最后一个字符的下标加一。
start( )和end(
)
如果匹配成功,start( )会返回此次匹配的开始位置,end( )会返回此次匹配的结束位置,即最后一个字符的下标加一。如果之前的匹配不成功(或者没匹配),那么无论是调用start(
)还是end( ),都会引发一个IllegalStateException。
注意,只要字符串里有这个模式,find( )就能把它给找出来,但是lookingAt( )和matches( ),只有在字符串与正则表达式一开始就相匹配的情况下才能返回true。matches( )成功的前提是正则表达式与字符串完全匹配,而lookingAt( )成功的前提是,字符串的开始部分与正则表达式相匹配。
匹配的模式(Pattern flags)
compile( )方法还有一个版本,它需要一个控制正则表达式的匹配行为的参数:
Pattern Pattern.compile(String regex, int flag)
flag的取值范围如下:
编译标志
|
效果
|
Pattern.CANON_EQ
|
当且仅当两个字符的"正规分解(canonical decomposition)"都完全相同的情况下,才认定匹配。比如用了这个标志之后,表达式"a\u030A"会匹配"?"。默认情况下,不考虑"规范相等性(canonical equivalence)"。
|
Pattern.CASE_INSENSITIVE
(?i)
|
默认情况下,大小写不明感的匹配只适用于US-ASCII字符集。这个标志能让表达式忽略大小写进行匹配。要想对Unicode字符进行大小不明感的匹配,只要将UNICODE_CASE与这个标志合起来就行了。
|
Pattern.COMMENTS
(?x)
|
在这种模式下,匹配时会忽略(正则表达式里的)空格字符(译者注:不是指表达式里的"\\s",而是指表达式里的空格,tab,回车之类)。注释从#开始,一直到这行结束。可以通过嵌入式的标志来启用Unix行模式。
|
Pattern.DOTALL
(?s)
|
在这种模式下,表达式'.'可以匹配任意字符,包括表示一行的结束符。默认情况下,表达式'.'不匹配行的结束符。
|
Pattern.MULTILINE
(?m)
|
在这种模式下,'^'和'$'分别匹配一行的开始和结束。此外,'^'仍然匹配字符串的开始,'$'也匹配字符串的结束。默认情况下,这两个表达式仅仅匹配字符串的开始和结束。
|
Pattern.UNICODE_CASE
(?u)
|
在这个模式下,如果你还启用了CASE_INSENSITIVE标志,那么它会对Unicode字符进行大小写不明感的匹配。默认情况下,大小写不明感的匹配只适用于US-ASCII字符集。
|
Pattern.UNIX_LINES
(?d)
|
在这个模式下,只有'\n'才被认作一行的中止,并且与'.','^',以及'$'进行匹配。
|
在这些标志里面,Pattern.CASE_INSENSITIVE,Pattern.MULTILINE,以及Pattern.COMMENTS是最有用的(其中Pattern.COMMENTS还能帮我们把思路理清楚,并且/或者做文档)。注意,你可以用在表达式里插记号的方式来启用绝大多数的模式。这些记号就在上面那张表的各个标志的下面。你希望模式从哪里开始启动,就在哪里插记号。
可以用"OR" ('|')运算符把这些标志合使用。
注意,group( )方法仅返回匹配的部分。
split( )
所谓分割是指将以正则表达式为界,将字符串分割成String数组。
String[] split(CharSequence charseq)
String[] split(CharSequence charseq, int limit)
这是一种既快又方便地将文本根据一些常见的边界标志分割开来的方法。
第二个split( )会限定分割的次数。
正则表达式是如此重要,以至于有些功能被加进了String类,其中包括split( )(已经看到了),matches( ),replaceFirst(
)以及replaceAll( )。这些方法的功能同Pattern和Matcher的相同。
替换操作
正则表达式在替换文本方面特别在行。下面就是一些方法:
replaceFirst(String replacement)将字符串里,第一个与模式相匹配的子串替换成replacement。
replaceAll(String replacement),将输入字符串里所有与模式相匹配的子串全部替换成replacement。
appendReplacement(StringBuffer sbuf, String replacement)对sbuf进行逐次替换,而不是像replaceFirst( )或replaceAll( )那样,只替换第一个或全部子串。这是个非常重要的方法,因为它可以调用方法来生成replacement(replaceFirst( )和replaceAll( )只允许用固定的字符串来充当replacement)。有了这个方法,你就可以编程区分group,从而实现更强大的替换功能。
调用完appendReplacement( )之后,为了把剩余的字符串拷贝回去,必须调用appendTail(StringBuffer
sbuf, String replacement)。
replaceFirst( )只替换第一个子串。此外,replaceFirst(
)和replaceAll( )只能用常量(literal)来替换,所以如果你每次替换的时候还要进行一些操作的话,它们是无能为力的。碰到这种情况,你得用appendReplacement( ),它能让你在进行替换的时候想写多少代码就写多少。在上面那段程序里,创建sbuf的过程就是选group做处理,也就是用正则表达式把元音字母找出来,然后换成大写的过程。通常你得在完成全部的替换之后才调用appendTail( ),但是如果要模仿replaceFirst( )(或"replace n")的效果,你也可以只替换一次就调用appendTail(
)。它会把剩下的东西全都放进sbuf。
你还可以在appendReplacement( )的replacement参数里用"$g"引用已捕获的group,其中'g' 表示group的号码。不过这是为一些比较简单的操作准备的,因而其效果无法与上述程序相比。
reset( )
此外,还可以用reset( )方法给现有的Matcher对象配上个新的CharSequence。
如果不给参数,reset( )会把Matcher设到当前字符串的开始处。
正则表达式与Java I/O
还需要StringTokenizer吗?
看到正则表达式能提供这么强大的功能,你可能会怀疑,是不是还需要原先的StringTokenizer。JDK 1.4以前,要想分割字符串,只有用StringTokenizer。但现在,有了正则表达式之后,它就能做得更干净利索了。
有了正则表达式,你就能用更复杂的模式将字符串分割开来——要是交给StringTokenizer的话,事情会麻烦得多。我可以很有把握地说,正则表达式可以取代StringTokenizer。
总结
Java的I/O流类库应该能满足你的基本需求:你可以用它来读写控制台,文件,内存,甚至是Internet。你还可以利用继承来创建新的输入和输出类型。你甚至可以利用Java会自动调用对象的toString( )方法的特点(Java仅有的"自动类型转换"),通过重新定义这个方法,来对要传给流的对象做一个简单的扩展。
但是Java的I/O流类库及其文档还是留下了一些缺憾。比方说你打开一个文件往里面写东西,但是这个文件已经有了,这么做会把原先的内容给覆盖了
。这时要是能有一个异常就好了——有些编程语言能让你规定只能往新建的文件里输出。看来Java是要你用File对象来判断文件是否存在,因为如果你用FileOutputStream或FileWriter的话,文件就会被覆盖了。
我对I/O流类库的评价是比较矛盾的;它确实能干很多事情,而且做到了跨平台。但是如果你不懂decorator模式,就会觉得这种设计太难理解了,所以无论是对老师还是学生,都得多花精力。此外这个类库也不完整,否则我也用不着去写TextFile了。此外它没有提供格式化输出的功能,而其他语言都已经提供了这种功能。
但是,一旦你真正理解了decorator模式,并且能开始灵活运用这个类库的时候,你就能感受到这种设计的好处了。这时多写几行代码就算不了什么了
2005年04月01日 1:13 AM