C/C++ Users Journal December, 2005
When programming in Standard C++, we have access to two main I/O facilities: Standard C's header cstdio and Standard C++'s stream-related headers. If we add Windows to the mix, we also have the Win32 library and the MFC library. Mix in CLI/.NET and we have yet another alternative.
This month, we will learn how to perform CLI/.NET-related formatted and unformatted I/O using the keyboard and screen; we'll also look at file and string I/O, random access I/O, and operations on files and directories. Note carefully that the C++/CLI Standard leaves the interaction (if any) of the Standard C++ and C++/CLI libraries unspecified.
We communicate with a file or device via a logical channel called a stream. Data may be read or written as 8-bit bytes or as 16-bit Unicode characters, with each form having its own set of classes. There are also classes for converting between byte and character streams. The byte streams are implemented by the class Stream and classes derived from that class. Character streams are implemented by the classes TextReader and TextWriter, and their descendants.
Example 1 shows the standard I/O class hierarchy. (Those with the explicit System namespace prefix are not involved in I/O, yet they are base classes for classes that are.)
Three streams are automatically opened for us when any application begins execution. They are:
The standard streams support various single-byte and multibyte character encodings. For example, large amounts of text in Japanese computing is stored not as Unicode, but rather, as characters that take up one or more bytes using various encodings such as JIS, Shift-JIS, and EUC. Similarly, in the Western world, a lot of text is stored using EBCDIC, and increasingly, in UTF-8 form. Character streams hide the complexity of dealing with these encodings, although some classes allow a particular encoding to be specified. A detailed discussion of character encodings is beyond the scope of this article.
Let's begin with the classes TextReader and TextWriter. These classes provide some basic primitives upon which all other character-stream-I/O classes are built. Listing 1 demonstrates some of these primitives.
In case 1, we make the standard I/O machinery available by importing from the namespace System::IO. In cases 2 and 3, we define two variables, inStream and outStream, that refer to the standard input and output streams, respectively.
In case 4, we read a character and write it straight out. Then we read and write another one. Note the explicit casts on the writes. This is needed because Read returns an int rather than a wchar_t, as we shall discuss later.
Output streams support the ability to have their buffers flushed, as shown in case 5.
As we can see in case 6, Read is overloaded. The first version we used read and returned a single wide character. This new version reads in a given number of wide characters and stores them in a wide character CLI array starting at a given offset. In this case, we read two characters and store them into buffer[1] and buffer[2], leaving buffer[0] and buffer[3] intact.
In case 7, we use an overloaded version of Write to write out the whole array.
In case 8, we call Copy to copy characters from input to output until end-of-file is reached. We then do a formatted write in case 9 using yet another overloaded version of Write, and finally, in cases 10 and 11, we close both streams.
We see in case 13 that the character we read is being stored in a variable of type int. The reason for this is that we need to return a value that represents any legitimate character value as well as end-of-file. By returning an int, Read can return -1 as the value for end-of-file, and a value in the range 0-65535 for each Unicode input character.
Figure 1 shows some input and the corresponding output. The digits 1 and 2 are read and written; then the digits 3 and 4 are read and stored in the second and third elements of the 4-character array. We then write out that array, which now contains w, 3, 4, and z. Then Copy reads and writes the remaining characters until end-of-file is reached, and finally, the formatted write outputs a line of text.
Except for the classes we use, there is little difference between performing I/O with a file and with the standard streams. The program in Listing 2 takes its input and output filename strings from the command line.
Note the new signature for main; as declared, argv is a handle to an array of handles to String. Unlike Standard C++'s main, this array does not include the string that names the program being run. Instead, argv[0] represents the first command-line argument.
Environment::Exit allows us to terminate the application normally in case 1 and to provide an exit status code (with nonzero values typically signifying something other than success). In case 2a, we define inFile to be a FileStream and we make it refer to an object of that type using the constructor taking a string corresponding to the input file's name, and an enumerated value that indicates a new file should be created. We then map a StreamReader to this FileStream in case 2b. These two steps can be combined into one, as shown in case 2c (which is commented out). Class File provides a number of static functions that allow FileStreams to be created. We use this simpler approach to create the StreamWriter in case 3.
As in the previous example, we call Copy to copy each character from input to output. Because StreamReader is derived from TextReader, and StreamWriter is derived from TextWriter, the call is valid, allowing us to write generic I/O functions.
In case 5, we perform a formatted write to the output stream and then close both streams.
The order of the catch blocks in cases 6 and 7 is most important. Since FileNotFoundException is derived from IOException, the base class must follow the derived class; otherwise, the derived class's catch block would never be reached.
Apart from providing various constructors, StreamReader and StreamWriter also support the same set of functions supported by TextReader and TextWriter shown in the previous section.
Just as we can read from and write to files, so too can we read from and write to strings; Listing 3 shows how, and the output is shown in Figure 2.
As we can see, by default, the StringWriter constructor creates an unnamed StringBuilder to store the text written, and this object has the default size of 16. An alternate constructor allows an existing StringBuilder to be used instead. In either case, the underlying StringBuilder is accessed via StringWriter::GetStringBuilder.
Until now, all of the examples have dealt with I/O of characters, and while this is adequate for some applications, others require different types to be written out in binary. Consider Listing 4.
A BinaryWriter must be associated with some form of output stream. Therefore, in case 1, we open a disk file and then in case 2, we associate the BinaryWriter to that file's stream. We do likewise for an input stream in cases 4 and 5.
In case 3 and subsequent statements, we see calls to various write functions. There is one defined for each primitive type, although only six of them are shown here. Similarly, in case 6 we see the corresponding read functions used. The output produced is shown in Figure 3.
We can open a file and access it with both a read and a write stream at the same time, and as we move through the file, we can save our current position so we can return to that position later on, either to reread that field, or to overwrite it; see Listing 5.
In case 3, we display the current file position, and in case 4, we save it in a variable by accessing the property FileStream::Position. We can restore to that position by setting that same property, as in cases 5 and 6. We can save as many positions as we like.
An alternative way of establishing the file's current position is to call FileStream::Seek, as shown in cases 7, 8, and 9. The first argument to this function is a byte count relative to the position specified by the second argument.
For example, in case 7, we position 0 bytes offset from the file's beginning. In case 8, we position 1 byte before the current position; that is, just before the Boolean we just read. In case 9, we position 8 bytes before the file's end, and proceed to read the double located there. In general, it's best to set Position to a value previously obtained from that property. In addition, seeking to the file's start and end is always safe; however, seeking to arbitrary byte positions could put us in the middle of a multibyte value, such that reading from that point would not be particularly meaningful. The output produced is shown in Figure 4.
The classes File and Path allow us to perform certain operations on files and path names, respectively. The example in Listing 6 demonstrates a number of their capabilities. The output produced when this application was run on a Win32 system is shown in Figure 5. Class Directory provides a family of directory-related functions.
Most useful applications depend on information of a more permanent nature than that generated during a single execution. For example, programs that access an inventory typically query (and possibly update) one or more related data files. The lives of such "master files" transcend that of the execution of any of the application programs that use them. Other applications involve the communication of messages between separate programs, often referred to as client and server. While the life of a message is often much shorter than that of a database record, both cases involve the use of some data format external to the applications that manipulate them. In addition, data records often contain objects as well as simple types. The process of saving and restoring objects is known as serialization, a topic we'll cover in a future installment.
While I/O is performed synchronously by default, it is possible to have it performed asynchronously; however, that capability is outside the scope of this article.
To reinforce the material we've covered, perform the following activities:
int GetInt(TextReader^ inStream).
-3 9 -27 -2 4 -8 -1 1 -1 0 0 0 1 1 1 2 4 8 3 9 27