Computers would not be the pervasive tools that they are today if there were no permanent way to save the information contained in the computer. First of all, we would be limited to amount of data that could be stored in memory. That restriction alone would severely limit the computer's usefulness as a business tool. Second, even if we could store all our information needs in memory, shutting the computer down would mean re-entering all that information again each time we wanted to use it. Clearly, there has always been a need for a means of permanent storage of computer data.
When microcomputers first came on the scene in the 1970s, the mass storage device was a cassette tape recorder! I can remember turning on the computer and having the lonely prompt of a bare-bones operating system staring at me. I would then issue a cryptic command telling the computer to load Tom Pittman's Tiny Basic from the cassette tape recorder, press Enter, and go fix lunch . If I made enough sandwiches and the computer was having a good day, when I returned, the 4- kilobyte BASIC interpreter would be ready for me to start programming. Although there were ways to save data to the tape recorder, it was so unreliable and slow, I rarely used such data files.
Then Northstar Computers came out with an operating system that supported 90-kilobyte disk drives using 5.25'' floppy diskettes. I thought I had died and gone to heaven! The drives were extremely fast (remember the frame of reference) and how could anyone ever need more than 90KB of storage? Indeed, I wrote an entire accounting system, including GL, AR, and AP, for my consulting business that fit on one 90KB diskette with room to spare!
I was so excited about the possibilities that I tried to convince one of my clients (a major insurance company) that these small computers would have a major impact on the way their agents would conduct day-to-day business. Alas, they didn't believe it and commented that " these computers will never do more than play games ." Well, in fairness to them, that was back in the late 1970s and there were some serious shortcomings, especially when it came to mass storage devices.
My, oh my, how things have changed. Now you can buy a mega-munch of disk storage for less than a couple of dollars per gigabyte. The availability of relatively inexpensive disk storage has played a major role in the acceptance of microcomputers in the business community. With that in mind, let's see how to use some of that storage space.
Sequential Disk Data Files
Data written to a sequential file follows the cassette tape recorder model mentioned earlier. That is, data is usually written starting at the beginning of the file (BOF) and copied byte-by-byte until all the data has been written. At that point, an end of file (EOF) marker is written and the file is closed. The sequential disk data file layout can be seen in Figure 23.1.
Figure 23.1. The data layout for a sequential disk data file.
The File Pointer
Try to visualize what happens when you read a disk file. When you open the file, a file pointer is moved to the BOF mark in the file. (Some people find it useful to think of the file pointer as the disk head that actually reads the data.) Each time you read one byte of data from the file, the file pointer moves one byte towards the EOF marker. Eventually, the file pointer reads all the data in the file and reads the EOF marker. The EOF marker tells Visual Basic .NET all the data in the file has been read. As we'll see later, we can move the file pointer anywhere we want in the file.
If you want to add new data to the file, you normally append the new data to the data that's already stored in the file. In other words, when you want to write new data to an existing sequential file, you move the file pointer to the current EOF marker and, starting at that marker, you begin writing the new data. The new data overwrites the old EOF marker and a new EOF marker is written to the end of the file.
Figure 23.2 shows the process of appending new data to an existing sequential file.
Figure 23.2. Appending data to sequential disk data file.
Sequential Files Are Dense
As you can see, adding new data to a sequential file is done by appending that new data to end of the old data. The result is a file that's dense. That is, there are no gaps in the data ”the bytes are packed one against the other from BOF to EOF and no space is wasted . That's the good news. That's also the bad news.
The problem with sequential files is that they're difficult to update. For example, suppose that you wrote the name of your friend to the file as Kathy . Sometime later, after you've added many other names to the file, you discover that Kathy prefers to be called Katherine. Because sequential files are dense, there are no empty spaces in the file that you can reclaim to expand the name. In fact, about the only way to make the change is to copy the old data to a new file up to the point where Kathy appears, write the new version, Katherine , to the new file, and then continue copying from the old file to the new file.
Given that sequential files are hard to update, why use them? Well, their real advantage is that they are dense data files and result in a very efficient use of disk space. They are perfect for some file types, such as letters and documents that aren't updated all that often. Our first sample program of this chapter is used to read and write a sequential file.
Adding a Program Menu
The first thing we want to do is create a new project named SequentialFile. Now move to the Toolbar and double-click on the MainMenu control. Your screen will look similar to that shown in Figure 23.3.
Figure 23.3. Adding a MainMenu control to a form.
Notice that the MainMenu control appears at the bottom of the design window and a small box labeled Type Here appears on the form. (Earlier versions of Visual Basic had a menu editor integrated into the IDE. That editor has been replaced with the MainMenu control.)
In the box labeled Type Here, type in the word &File , a menu option that's often placed first in a Windows menu list. When you start typing into the box, you'll see two new Type Here boxes. One of these boxes will be located below the File menu, and a second box will appear immediately to the right of File. You've probably already figured out what these choices mean, but we'll go through them anyway.
If you press the Enter or down-arrow key, the cursor moves into the Type Here box that's below the File menu. This enables you to add a new menu option that's a submenu of File. Now type in the word &New and press the down-arrow key again. Now type in &Close and again press the down-arrow key. Now touch the hyphen (or minus sign) key and then press the down-arrow key. This causes a menu separator to be drawn on the menu. Finally, type in E&xit . Your screen should look similar to that shown in Figure 23.4.
Figure 23.4. Adding submenu options to the File menu.
These are the only submenu options for the File menu we need for the moment. Now click on the File menu option you entered earlier and change its name to mnuFile . (If the Properties Window isn't visible, press the F4 key.) Change the names for the submenu options the same way (for example, mnuNew , mnuOpen , mnuClose , and mnuExit ).
Now click on the Type Here menu option that's to the right of the File menu option and type in &View . Now add &Random and &Sequential submenus to the View menu and set their respective names to mnuView , mnuRandom , and mnuSequential . (We don't actually use these last two menus in this program. However, I want you to see how to move sideways and add new menu options.) We're done with the menus for the program.
Now move to the Toolbar again and add an OpenFileDialog control, a SaveFileDialog control, and finally a rich text box control. If you have done all of these correctly, your display should look similar to that shown in Figure 23.5.
Figure 23.5. The complete SequentialFile form.
Change the name of the rich text box to rtbOutput . Place rtbOutput so that its upper-left corner is near the upper-left corner of the form. Don't worry about the size of rtbOutput . We'll resize it while the program is running.
Writing a Text File
First of all, we need to import System.IO into our program because it's responsible for all Visual Basic .NET file processing. Listing 23.1 shows the code necessary for writing a text data file. Although we could set things up in terms of a reading class or a writing class (did those just sound like high school electives?), I decided to keep the sequential access code as simple as possible and place all the code in one place.
First, we import the System.IO namespace. The form Load event does nothing more than hide the rich text box.
Listing 23.1 Code for the mnuSave Menu Option
Imports System.IO Public Class frmSequential Inherits System.Windows.Forms.Form ' Windows Form Designer generated code Private Sub frmSequential_Load(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles MyBase.Load rtbOutput.Visible = False End Sub Private Sub mnuNew_Click(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles mnuNew.Click rtbOutput.Clear() ' Clear rtb SizeRichTextbox() ' Set up the rtb rtbOutput.Visible = True rtbOutput.Focus() End Sub Private Sub SizeRichTextbox() ' Resize the rich textbox Dim ClientSize, BoxSize As Size ClientSize = Me.Size BoxSize.Height = ClientSize.Height - 100 BoxSize.Width = ClientSize.Width - 100 rtbOutput.Size = BoxSize End Sub Private Sub mnuSave_Click(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles mnuSave.Click Dim NewFileName As String Dim NewFile As SaveFileDialog = New SaveFileDialog() Dim MyChoice As DialogResult With NewFile .Filter = "Text Files (*.txt)*.txtAll Files (*.*)*.*" .FilterIndex = 1 ' Assume text files .DefaultExt = "txt" ' Ditto .InitialDirectory = "C:\Temp\" .OverwritePrompt = True ' Ask before overwriting .Title = "Save Text Data File" End With MyChoice = NewFile.ShowDialog ' What did they do? If MyChoice = DialogResult.Cancel Then ' Bail out? Exit Sub Else NewFileName = NewFile.FileName End If If NewFileName.Length > 0 Then Try Dim MyTextData As StreamWriter = New StreamWriter(NewFileName, False) MyTextData.Write(rtbOutput.Text) ' Write the data MyTextData.Close() ' Close the stream MyTextData = Nothing ' Release it Catch Beep() MessageBox.Show("Something went wrong while writing of the data") End Try End If End Sub
First, notice that we're responding to a menu click event rather than a button click event. Actually, the code is virtually the same whether we respond to a button or menu click.
The mnuNew Click Event
When the user wants to start a new text file, he clicks on the New submenu option of the File menu. To add the code, simply double-click the New menu option in Design mode and Visual Basic .NET switches to the Code window and fills in the subroutine shell for the mnuNew_Click event code.
The code really doesn't do much. First, it uses the rich text box Clear() method to erase any text that might be left from a previous run. It then calls SizeRichTextbox() , which is a small subroutine we wrote to resize the text box to fill most of the client space for the form. Note that we define two working variables named ClientSize and BoxSize of type Size . The Size class enables us to determine the size of the form. We then set the BoxSize Height and Width members to be 100 pixels smaller than the main form's size. This results in a rich text box that fills most of the client area of the form.
After the call to SizeRichTextbox() , we set the Visible property of the text box to True and place the focus in the control. The user can now start typing into the text box. When the user is finished, he can elect to save the contents of the text box to a disk file.
The mnuSave Click Event
When the user clicks on the Save submenu option, the mnuSave Click event code is executed. We define NewFile as a SaveFileDialog() object. It's very similar to the OpenFileDialog() object we used in Chapter 22, "Visual Basic .NET Graphics," and requires similar initialization statements. Because we're interested only in saving text files for the moment, we set the FilterIndex property for NewFile to default to *.txt files and the DefaultExt to txt . The DefaultExt is appended to a filename when the user simply types in a primary filename and does not add the file's extension. Therefore, if the user types in MyFile for the filename, the file is actually written to the disk as MyFile.txt .
The OverwritePrompt property of NewFile is important. If the file name entered by the user already exists on the disk, setting this property to logic True causes the dialog to ask the user if he wants to overwrite the existing file. If this property is set to logic False , the dialog assumes that the writer knows what he's doing (usually not a good assumption) and does not ask if he wants to overwrite the file. Because a logic False setting for this property could result in losing the existing contents of the file, I think you should always set this property to logic True .
MyChoice is defined as an object of type DialogResult and is used to tell us what the user did with the SaveFileDialog() dialog box. If the user elected to cancel the operation, the If test on MyChoice causes the code to exit the subroutine. Otherwise, we assign the dialog's FileName property into NewFileName .
If NewFileName has a string length greater than 0, we assume that the user wants to save the data to a text file. We define a StreamWriter() object named MyTextData to write the contents of the rich text box to the data file. In the statement
Dim MyTextData As StreamWriter = New StreamWriter(NewFileName, False)
the first argument to the StreamWriter() constructor is the name of the file that the user entered into the dialog box. The second argument is a Boolean that determines whether the data about to be written is appended to the file. If the file already exists and the argument is logic True , the data is appended to the file. If the argument is logic False , any existing data is overwritten.
We've set the argument to logic False , which means that whatever appears in the rtbOutput text box overwrites any data that might have previously existed in the file. If you need to preserve the existing data in the file, make sure that you set this argument to logic True . For our testing purposes here, overwriting should be fine. The actual writing of the data is a one-line statement
MyTextData.Write(rtbOutput.Text) ' Write the data
which writes the text in the rtbOutput text box to disk.
The next statement closes the file. It's important that you close the file for two reasons. First, most input/output (I/O) writing operations done in the Windows environment use what is known as buffered I/O. Because I/O operations are relatively slow, Windows minimizes the number of physical I/O operations whenever possible. When you use the Write() method for a buffered I/O operation, the data is actually moved to a memory buffer, not to disk. In other words, Windows delays the actual I/O activity as long as possible. If the buffer becomes full, Windows flushes the contents of the buffer to disk. If the buffer doesn't become filled, Windows waits until it does become full or you're finished using the file. When you call the Close() method, Windows flushes the contents of that memory buffer to disk by performing a physical I/O operation. Therefore, you should call the Close() method when you're finished using the file to make sure that the data is written to disk. If you don't make the Close() call and something nasty happens (for example, a power loss), the data might be lost.
The second reason for closing the file is so that you can perform the next statement:
MyTextData = Nothing ' Release it
This allows Windows to mark the resources associated with the StreamWriter object available for garbage collection. By doing this, you don't tie up resources that are no longer needed by the program.
Finally, notice that we embedded the file save code in a Try - Catch statement block. After all, things can go wrong, especially with physical I/O devices. We've used a generic Catch block, but it would at least give the user a clue that something is amiss. If you want to add a more precise Catch block, you can use the exception list (for example, the System.IO common language runtime exceptions) found under the Debug - Exceptions menu sequence. The details are covered in Chapter 19, "Error Processing and Debugging."
That's it! We've now written code that can move data from a text box to a disk data file. Or, at least we think we did. Let's write the code that enables us to review a text file to see whether we really did things correctly. The mnuOpen Click event code is shown in Listing 23.2.
Listing 23.2 The mnuOpen Click Event Code
Private Sub mnuOpen_Click(ByVal sender As Object, ByVal e As _ System.EventArgs) Handles mnuOpen.Click Dim FileName As String rtbOutput.Clear() With OpenFileDialog1 .Filter = "Random files (*.dat)*.datText Files (*.txt)*.txtAll _ Files (*.*)*.*" .FilterIndex = 2 .InitialDirectory = "C:\Temp\" .Title = "Select File" End With Try If OpenFileDialog1.ShowDialog() = DialogResult.OK Then ' Try to open it FileName = OpenFileDialog1.FileName Else Exit Sub ' They didn't want to continue... End If Select Case OpenFileDialog1.FilterIndex Case 1 ' Random Case 2 ' Text Dim MyReadText As StreamReader = New StreamReader(FileName) rtbOutput.Visible = True SizeRichTextbox() rtbOutput.Text = MyReadText.ReadToEnd() MyReadText.Close() MyReadText = Nothing Case Else End Select Catch MessageBox.Show("Something's amiss...", "File Read Error") End Try End Sub
The mnuOpen Click event code is used to select and open a text file. The code begins by clearing any previous contents from the rtbOutput text box using the Clear() method. Next, we initialize the properties of the OpenFileDialog1 object. These should look familiar to you by now, so we don't need to go over them again here. If you want to review the OpenFileDialog properties that we're using, see Chapter 22 or use Visual Basic .NET's Online help.
Again, we nest the code within a Try - Catch block in case something goes wrong. The test expression of the If statement checks to see whether the user selected a file. If the user cancelled the operation, we exit the subroutine. Otherwise, we assign the filename she selected into FileName .
We use a Select Case statement block to actually hold the code for reading the text file. (We did this because we'll use this same program to experiment with random access files later in the chapter.) We use the FilterIndex property the OpenFileDialog1 object to determine which Case statement block to execute.
In the Case 2 statement block, we create a StreamReader object named MyReadText . We create MyReadText by passing the StreamReader constructor the name of the file that was just selected by the user. We set the rtbOutput Visible property to logic True and then call SizeRichTextBox() to resize the control. All the actual work to read the file is done with the statement
rtbOutput.Text = MyReadText.ReadToEnd()
which simply reads the selected text file from BOF to EOF (see Figure 23.1) and moves the data to rtbOutput . After the data is read, we close the file with the Close() StreamReader method and release the resources that we used by setting MyReadText to Nothing .
A sample run of the program is shown in Figure 23.6. As you can see, reading and writing sequential files is pretty simple. However, there are situations in which the format for sequential files leaves a lot to be desired, especially when we want to selectively retrieve data from a file. It's this selective data retrieval that I want to discuss in the next section.
Figure 23.6. A sample run of the SequentialFile program.
Random Access Data Files
Sequential data files provide an easy way to write data to a disk file. Sequential files are good choice for text files and can even be used for storing comma-separated variables (CSV) in a file. However, a major drawback of sequential files is that there's no convenient way to organize the data for a record-based retrieval system. An example will help explain.
Suppose we want to create a data file to store a mailing list. Each person in the list will have a first and last name, an address, and a city, state, and ZIP Code. We'll assume that these pieces of data uniquely describe each person in the list. Collectively, these pieces of data can be grouped together into what is called a record. However, one person's record might use only 35 bytes of disk space, whereas the next person's record might use 50 bytes of data. The storage for each record can vary because their names and addresses differ . In other words, if we view the data for each person as a record, each record is a variable-length record, the length of which varies according to the actual data in the record.
Although there's nothing to prevent us from writing variable-length records to a data file, getting the data back is a little inefficient. For example, if you need the address for the person you know is the tenth record in the file, you'll have to read through nine (unwanted?) records to get the address you want. Not bad, but not good, either.
Let's try another approach to the problem. Suppose we say that each record will be limited to 100 bytes, no more, no less. We might set these limits as shown in Table 23.1.
Table 23.1. Bytes in a Fixed-Length Record
It's common to refer to each variable in a record as a field, and that's how we'll refer to them from now on. Collectively, the fields form a record. If a person's first name is less than 10 bytes, we add enough blank spaces to make that field 10 bytes long. If the last name takes more than 20 spaces, we chop the field off at 20. By padding the fields that are short of the required byte length with blank spaces and truncating any fields that are too long, we can force each record to be exactly 100 bytes in length. So what?
Well, it gives us a very efficient means by which to retrieve records from the file. You saw earlier in this chapter that each file is associated with a file pointer that can be moved around within the file. Now let's return to our friend hanging out in record number 10. Suppose that we place the file pointer at ByteOffset and start reading the data:
ByteOffset = (DesiredRecord - 1) * FixedRecordLength = (10 - 1) * 100 = 900
What would we read? We can figure out the answer by looking at Figure 23.7. If we position the file pointer 900 bytes into the file, we're ready to read the first byte for record number 10. Perfect! The really good news is that we didn't have to read any data we didn't want ”we just skipped over the unwanted data by positioning the file pointer to the start of the desired record. Repositioning the file pointer is much faster than reading the data. As a result, we can read a desired record much faster using the approach described here rather than performing a sequential read of the data to the desired record.
Figure 23.7. A sample run of the SequentialFile program.
The positioning of the file pointer is similar to the way you fast-forward your VCR recorder. If, for example, you know that Katherine's birthday party starts 90 minutes into the tape, you can fast-forward to the proper place on the tape in a matter of seconds rather than sitting through 90 minutes of Uncle Sonny's mime act.
Using fixed-length records, we can skip to any record in the file that we want without having to read any unwanted data. Files that are based on fixed-length records are called random access files. They have that name because we can randomly move around within the file and read a given record. Sequential files don't give us the same flexibility.
Given that we can access records much faster using random access files, what's the downside? The downside is that we'll likely waste disk space because we end up padding a lot of the fields with blank spaces. Still, when you can buy disk storage for a few dollars per gigabyte, most users are more than happy to pay the wasted storage price to gain faster access to the data.
Using Random Access Files
Let's add random access files to our SequentialFile project. We need to make only a few code modifications to frmMain . First, we need to add code that enables us to create a new random access data file. This new code is shown in Listing 23.3.
Listing 23.3 Code for the mnuNewRandom Click Event
Private Sub mnuNewRandom_Click(ByVal sender As System.Object, ByVal e _ As System.EventArgs) Handles mnuNewRandom.Click Dim MyRandom As New frmRandom() Dim FileName As String rtbOutput.Visible = False With OpenFileDialog1 .Filter = "Random files (*.dat)*.datAll Files (*.*)*.*" .FilterIndex = 1 .InitialDirectory = "C:\Temp\" .Title = "Select Random File" End With Try If OpenFileDialog1.ShowDialog() = DialogResult.OK Then FileName = OpenFileDialog1.FileName Dim MyFile As FileStream = New FileStream(FileName, FileMode.Create,_ FileAccess.ReadWrite) MyFile.Close() MyFile = Nothing Else Exit Sub ' They didn't want to continue... End If Catch MessageBox.Show("Could not access file") End Try MyRandom.FileName = FileName MyRandom.Show() End Sub
Next, we define some working variables and hide the rich text box that we used to display the sequential files. We then set the OpenFileDialog1 properties to default to random access data files. I use the filename extension of .dat , but you can use any extension you want. A Try - Catch statement block is used to catch any I/O errors that might occur. We define a new FileStream object, passing it the name that the user typed in for the new file. We don't actually write any data to the file at this time. Instead, we simply create the file by setting the second argument of the FileStream constructor to FileMode.Create . We then close the file and release its resources.
MyRandom.FileName = FileName MyRandom.Show()
copy the current filename to the frmRandom class member named FileName and then load the frmRandom form. We'll discuss frmRandom in a moment, but first I want to discuss the second code change in the frmMain form.
The second code change amounts to three new lines to the mnuOpen Click event code in Listing 23.2. If you look at the Select Case statement block in that listing, you'll see that Case 1 is empty. Add the following three lines to Case 1 :
Dim MyRandom As New frmRandom() MyRandom.FileName = FileName MyRandom.Show()
These three lines duplicate the functionality of the last two lines in Listing 23.3. That is, Case 1 is designed to pass the name of the file the user wants to open to the frmRandom class code. Now we can see what frmRandom is all about.
The frmRandom Class Code
First, frmRandom has the form shown in Figure 23.8 associated with it.
Figure 23.8. The frmRandom form.
The text boxes are named txtFirst , txtLast , txtAddr , txtCity , txtState , and txtZip . The buttons in the middle row of are named btnAddNew , btnRead , btnSave , and btnExit . The buttons in the bottom row of are named btnFirst , btnNext , btnPrevious , and btnLast . We placed these last four buttons in a group box to reinforce the idea that they're associated with navigating around the random access file.
Adding a Record to the Random Access File
The first time you opt to use a random access file, you select a file name for the random file as part of the frmMain code. An empty random file is created and the form shown in Figure 23.8 appears. You then click the New button. (The code for the New button and its associated ClearForm() code are shown in Listing 23.4.) The form uses several member variables and defines a clsList class object for use by the frmRandom class. The name of the random access file that was selected is held in mFileName , as shown in Listing 23.3. This shows how easy it is to pass data between class forms without using global data. (We're always mindful of the encapsulation goal of OOP, right?)
The form Load event copies the random access file name into the file name member of the clsList class for later use. Next, we get the present record count for the file and assign it into LastRecord . We also set the CurrentRecord to 1 . The If test checks to see whether there are any records in the file. If so, a call to FillARecord() is made to display the contents of the first record in the file. However, because this is the first time we've run the program, the file is empty and the text boxes remain empty.
Listing 23.4 The Code for btnAddNew and ClearForm()
Public Class frmRandom Inherits System.Windows.Forms.Form ' ====================== Members ==================== Private MyList As New clsList() Private mFileName As String Private CurrentRecord As Long Private LastRecord As Long ' Windows Form Designer generated code ' ====================== Properties ================= Public Property FileName() As String Get Return mFileName End Get Set(ByVal NewFileName As String) mFileName = NewFileName End Set End Property ' ================= Form Load Event ==================== Private Sub frmRandom_Load(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles MyBase.Load Dim i As Integer MyList.FileName = mFileName ' Copy open file name to class LastRecord = MyList.CurrentRecordCount ' How many records there? CurrentRecord = 1 If LastRecord > 0 Then ' If there are any records... FillARecord(1) ' ...show the first one End If End Sub Private Sub btnAddNew_Click(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles btnAddNew.Click Dim NewRecord As Long ClearForm() txtFirst.Focus() End Sub Private Sub ClearForm() ' Just clear the text boxes txtFirst.Text = "" txtLast.Text = "" txtAddr.Text = "" txtCity.Text = "" txtState.Text = "" txtZip.Text = "" End Sub Private Sub txtState_Leave(ByVal sender As Object, ByVal e As _ System.EventArgs) Handles txtState.Leave txtState.Text = UCase(txtState.Text) ' Convert state to upper case End Sub
At this point, the user starts entering data into the text boxes. There's nothing new going on here. The Leave event code for the state text box converts the content to uppercase letters. Other than that, we do nothing with the text boxes.
After all the fields have been filled in, the user clicks the Save button. The code for the btnSave Click event is shown in Listing 23.5.
Listing 23.5 The btnSave Click Event Code
Private Sub btnSave_Click(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles btnSave.Click Dim NewRecord As Integer, ErrorFlag As Integer MyList.First = txtFirst.Text ' Copy the data to class members MyList.Last = txtLast.Text MyList.Addr = txtAddr.Text MyList.City = txtCity.Text MyList.State = txtState.Text MyList.Zip = txtZip.Text NewRecord = MyList.CurrentRecordCount + 1 ' Bump the record counter MyList.WriteRecord(NewRecord) If ErrorFlag = 0 Then MessageBox.Show("Data written to file.") End If End Sub
The btnSave Click event code copies the data from the text boxes into the clsList members. NewRecord is assigned the current record count of the file as held in the mCurrentRecordCount member of the clsList class. We add 1 to the current value because we're adding a new record to the file. We then call the WriteRecord() method of the clsList class to write the new record data to the random access file. The code for the clsList is shown in Listing 23.6.
Because the clsList class performs file operations, we import the System.IO namespace. The System.IO namespace contains all the file I/O classes we need to work with disk data files.
Using Named Constants
The class begins with a list of named constants that specify the length that we assigned to each field of a random access record. We need to do this because each record in a random access file must be a fixed length.
Why do we use named constants? There are two reasons. First, using named constants avoids magic numbers in the code. Which of the following two statements is easier for you to understand?
If Len <= 10 Then
If Len <= FIRST_NAME_LENGTH Then
Named constants help document the code and aid in understanding what the code does.
The second advantage of named constants is that if we ever decide to change the size of one of the fields within the record, we need to make the change in only one place and recompile the program. All the places where the named constant are used in the code are automatically updated ”we don't have to search through the file looking for magic numbers to change.
In our example, the fixed length of a record is 87 bytes. This means that instead of skipping along in 100-byte chunks as we did in Figure 23.7, we're skipping along in 87-byte chunks . The mechanics used to describe Figure 23.7 still apply; we're just taking smaller steps as we walk through the file. Note that we set mRecordSize to equal the fixed record length.
Why Use Character Arrays Instead of Strings?
Now for the weird part. Notice that we've defined each field member as a character array rather than using a string. The reason for doing this is because there is a better correspondence between the data and the mechanics of writing random access data to the file. Let me explain.
If you write string data to a disk file, Visual Basic .NET prepends some data to the string before actually writing the string data to the file. For example, when writing string data, a FilePut() operation writes 2 bytes of data that describes the data type about to be written to the file, followed by another 2 bytes that tell the length of the string. (In some other languages, these two data items form what is called a string descriptor block. ) Finally, the actual string data is written to the file.
When Visual Basic .NET reads the file using a FileGet() operation, it uses the string descriptor block information to read the data correctly. The problem is that you might think you wrote only a 10-byte string to the file, but you actually wrote the string plus a 4-byte string descriptor block. Needless to say, this messes up our calculation for the fixed record length.
Note that defining the strings with fixed record lengths, as in
<VBFixedString(10)> Public First As String
does not solve the problem. The string descriptor block would still be written to the file.
On the other hand, character arrays aren't strings and don't use a string descriptor block. We use the named constants to set the length of each character array. When we actually write the character arrays to the file, we use the binary mode for writing, which avoids writing any descriptor block information to the file. The binary read operations take care of formatting the data into the correct data type when we read the data out from a binary file.
You might ask, "Why not just add the length of the descriptor blocks to the fixed file length and be done with it?" The first reason is because I didn't want to. The second reason is that I don't need the ( wasteful ) descriptor blocks in the file because the clsList class manages the file data for us. The third reason is because I wanted you to see how to use binary disk files.
Listing 23.6 The clsList Code
Imports System.IO Public Class clsList ' Set the length for each data field Private Const FIRST_NAME_LENGTH As Integer = 10 Private Const LAST_NAME_LENGTH As Integer = 20 Private Const ADDR_LENGTH As Integer = 30 Private Const CITY_LENGTH As Integer = 15 Private Const STATE_LENGTH As Integer = 2 Private Const ZIP_LENGTH As Integer = 10 ' ====================== Data members ======================== ' Calculate record size, which is currently 87 bytes Private mRecordSize As Long = FIRST_NAME_LENGTH + _ LAST_NAME_LENGTH + _ ADDR_LENGTH + CITY_LENGTH + _ STATE_LENGTH + ZIP_LENGTH Private mFirst(FIRST_NAME_LENGTH) As Char ' The actual field data Private mLast(LAST_NAME_LENGTH) As Char Private mAddr(ADDR_LENGTH) As Char Private mCity(CITY_LENGTH) As Char Private mState(STATE_LENGTH) As Char Private mZip(ZIP_LENGTH) As Char Private mFileName As String ' Name of data file Private mCurrentRecord As Long ' The current record number Private mCurrentRecordCount As Long ' Records in the file Private Len As Integer ' Some working variables Private buff As String ' =================== Properties ===================== Public Property First() As String ' First Name Get Return mFirst End Get Set(ByVal Value As String) Len = Value.Length If Len <= FIRST_NAME_LENGTH Then buff = Value & Space(FIRST_NAME_LENGTH - Len) Else buff = Value.Substring(0, FIRST_NAME_LENGTH) End If mFirst = buff.ToCharArray() End Set End Property Public Property Last() As String ' Last name Get Return mLast End Get Set(ByVal Value As String) Len = Value.Length If Len <= LAST_NAME_LENGTH Then buff = Value & Space(LAST_NAME_LENGTH - Len) Else buff = Value.Substring(0, LAST_NAME_LENGTH) End If mLast = buff.ToCharArray() End Set End Property Public Property Addr() As String ' Street address Get Return mAddr End Get Set(ByVal Value As String) Len = Value.Length If Len <= ADDR_LENGTH Then buff = Value & Space(ADDR_LENGTH - Len) Else buff = Value.Substring(0, ADDR_LENGTH) End If mAddr = buff.ToCharArray() End Set End Property Public Property City() As String ' City Get Return mCity End Get Set(ByVal Value As String) Len = Value.Length If Len <= CITY_LENGTH Then buff = Value & Space(CITY_LENGTH - Len) Else buff = Value.Substring(0, CITY_LENGTH) End If mCity = buff.ToCharArray() End Set End Property Public Property State() As String ' State Get Return mState End Get Set(ByVal Value As String) Len = Value.Length If Len <= STATE_LENGTH Then buff = Value & Space(STATE_LENGTH - Len) Else buff = Value.Substring(0, STATE_LENGTH) End If mState = buff.ToCharArray() End Set End Property Public Property Zip() As String ' Zip Code Get Return mZip End Get Set(ByVal Value As String) Len = Value.Length If Len <= ZIP_LENGTH Then buff = Value & Space(ZIP_LENGTH - Len) Else buff = Value.Substring(0, ZIP_LENGTH) End If mZip = buff.ToCharArray() End Set End Property Public Property FileName() As String ' File name Get Return mFileName End Get Set(ByVal Value As String) mFileName = Value End Set End Property Public ReadOnly Property RecordSize() As Long ' record size ' ReadOnly because user should never change this Get Return mRecordSize End Get End Property Public ReadOnly Property CurrentRecordCount() As Long ' record count ' ReadOnly because user should never change this Get If mCurrentRecordCount = 0 Then GetRecordCount() End If Return mCurrentRecordCount End Get End Property ' ===================== Methods =================== Public Function WriteRecord(ByVal ThisRecord As Integer) As Integer ' Purpose: To write a random record ' ' Argument list: ' ThisRecord the record number to write ' ' Return value: ' integer 0 if OK, 1 on error Dim ErrorFlag As Integer Dim MyFile As FileStream Dim MyBinaryObject As BinaryWriter Try ' Set things up to write a record MyFile = New FileStream(mFileName, FileMode.Open, FileAccess.Write) MyBinaryObject = New BinaryWriter(MyFile) MyFile.Position = (ThisRecord - 1) * mRecordSize MyBinaryObject.Write(mFirst) MyBinaryObject.Write(mLast) MyBinaryObject.Write(mAddr) MyBinaryObject.Write(mCity) MyBinaryObject.Write(mState) MyBinaryObject.Write(mZip) MyFile.Close() MyFile = Nothing MyBinaryObject = Nothing mCurrentRecordCount += 1 ' Up the record counter Catch MessageBox.Show("Something went wrong in WriteRecord().", "I/O Error") ErrorFlag = 1 End Try End Function Public Function ReadRecord(ByVal ThisRecord As Integer) As Integer ' Purpose: To read a random record and fill in members ' ' Argument list: ' ThisRecord the record number to read ' ' Return value: ' integer 0 if OK, 1 on error Dim ErrorFlag As Integer Dim MyFile As FileStream Dim MyBinaryObject As BinaryReader Try MyFile = New FileStream(mFileName, FileMode.Open, FileAccess.Read) MyBinaryObject = New BinaryReader(MyFile) MyFile.Seek((ThisRecord - 1) * mRecordSize, SeekOrigin.Begin) mFirst = CStr(MyBinaryObject.ReadChars(FIRST_NAME_LENGTH)) mLast = CStr(MyBinaryObject.ReadChars(LAST_NAME_LENGTH)) mAddr = CStr(MyBinaryObject.ReadChars(ADDR_LENGTH)) mCity = CStr(MyBinaryObject.ReadChars(CITY_LENGTH)) mState = CStr(MyBinaryObject.ReadChars(STATE_LENGTH)) mZip = CStr(MyBinaryObject.ReadChars(ZIP_LENGTH)) MyFile.Close() MyFile = Nothing MyBinaryObject = Nothing Catch MessageBox.Show("Read error in ReadRecord()", "I/O Error") ErrorFlag = 1 End Try Return ErrorFlag End Function ' ===================== Helpers =================== Private Sub GetRecordCount() Try If mFileName.Length <> 0 Then ' Find out how many records mCurrentRecordCount = FileLen(mFileName) / mRecordSize End If Catch fileexception As FileNotFoundException ' If a new file Dim MyFile As FileStream = New FileStream(mFileName, FileMode.Create, FileAccess. ReadWrite) MyFile.Close() MyFile = Nothing mCurrentRecordCount = 0 End Try End Sub End Class
Most of the code in the file is used to get and set the properties in the list. The interesting sections of Listing 23.6 are in the methods that read and write the data. Let's walk through the WriteRecord() method first.
Writing the Data to the Random Access File
The btnSave Click event in frmRandom calls the WriteRecord() method of the clsList class. The argument passed to the WriteRecord() method determines which record in the file is about to be written. This record number is assumed to be a one-based record. That is, if the value passed in for ThisRecord is 1 , this is the first record in the file. As such, it has a 0-byte offset in the file. Notice that when we set the file pointer position with the statement
MyFile.Position = (ThisRecord - 1) * mRecordSize
we convert the one-based (human) record number to a zero-based (computer) record number.
The actual disk operations are nested in a Try - Catch block just in case something goes wrong. First, we open the file using the FileStream() method. The statement that opens the file is
MyFile = New FileStream(mFileName, FileMode.Open, FileAccess.Write)
The first argument is the name of the file we're using. The second argument is the file operation we want to perform. In this case, we want to open the file. (If you look at the GetRecordCount() method, you'll see that we used FileMode.Create to actually create the new random access file.) The final argument determines how we want to use the file. At this point, we want to write data to the file. The FileStream() method returns a FileStream object, which we use to create a BinaryWriter object:
MyBinaryObject = New BinaryWriter(MyFile)
After we've defined MyBinaryObject , we position the file pointer to the proper offset in the file using the record length as held in mRecordSize . Finally, we use the Write() method of the BinaryObject to actually lay the data on the disk.
Binary disk writes are used because they write raw binary data to the disk. Binary writes do not, for example, write any descriptor blocks to the file. Therefore, when it comes time to read the data back from the disk, we need to use the proper binary Read() method. (We'll cover this in the next section of the chapter.) Although we have to give a little more thought to things with random access file data than we did with the sequential file data, the retrieval advantages far outweigh any disadvantages.
We immediately close the file after the data is written and set both file objects to Nothing . This allows Windows to perform its magic via garbage collection to free the resources associated with the file objects. If we get this far without an error, the data has been safely written to the disk, so we increment mCurrentRecordCount by 1.
Reading a Random Access File
Reading the data back from a random access file parallels the writing of the data. When we define the FileStream() object:
MyFile = New FileStream(mFileName, FileMode.Open, FileAccess.Read)
we simply change the third argument to FileAccess.Read . Next, we define a BinaryRead object, passing its constructor the name of the FileStream object, MyFile . The statement
MyFile.Seek((ThisRecord - 1) * mRecordSize, SeekOrigin.Begin)
uses the Seek() method to position the file pointer to the byte position in the file where we want to start reading the data. The first argument calculates the (zero-based) byte offset value, and the second argument is used to determine the frame of reference for the offset. SeekOrigin.Begin is an Enum that says we want to position the file pointer relative to the beginning of the file (BOF). You can also use SeekOrigin.Current , which uses the offset bytes from the current position of the file pointer, or you can use SeekOrigin.End , which calculates the offset from EOF.
After the Seek() method has done its thing, the file pointer is ready to read the data from the file. The series of read statements that begin with
mFirst = CStr(MyBinaryObject.ReadChars(FIRST_NAME_LENGTH))
uses the ReadChars() method to read the raw character data from the file and assign the data to their respective strings. The CStr() conversion is not strictly required because Visual Basic .NET performs a silent cast to convert the data from a character array to a string. However, using CStr() helps document exactly what our intensions are. This is a good thing; silent casts are not.
The BinaryReader class provides 16 Read XXX methods for reading binary data from a file (for example, ReadInt32 , ReadString , ReadDouble , and so on). These different methods enable you to read just about any type of binary data that you want from a binary file. The routines are necessary because, unlike text files that contain formatting data as part of the file (for example, string descriptor blocks), binary data is written to the disk as raw binary data. The various Read XXX methods provide the means by which Visual Basic .NET can read the raw binary data and format it into the proper data type.
Figure 23.9 shows a sample run of the program. Notice the title bar on the form. It tells us that we're looking at the fourth record in the file.
Figure 23.9. A sample run reading a random access data file.
Navigating Through the Records of a Random Access File
Now that we've added a few records to the random access file, we can use the navigation buttons shown near the bottom of the frmRandom form shown in Figure 23.9. The code for the buttons is shown in Listing 23.7.
Listing 23.7 The Code for the Random Access File Navigation Buttons
' ===================== Navigation buttons =========================== Private Sub btnFirst_Click(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles btnFirst.Click CurrentRecord = 1 FillARecord(CurrentRecord) End Sub Private Sub btnnext_Click(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles btnnext.Click CurrentRecord += 1 If CurrentRecord > LastRecord Then CurrentRecord = LastRecord End If FillARecord(CurrentRecord) End Sub Private Sub btnLast_Click(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles btnLast.Click CurrentRecord = LastRecord If CurrentRecord < 1 Then CurrentRecord = 1 End If FillARecord(CurrentRecord) End Sub Private Sub btnPrevious_Click(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles btnPrevious.Click CurrentRecord -= 1 If CurrentRecord < 1 Then CurrentRecord = 1 End If FillARecord(CurrentRecord) End Sub Private Sub FillARecord(ByVal rec As Long) ' Purpose: This subroutine is used to fill in the form's ' text boxes. ' ' Argument list: ' rec a long that is the record number to retrieve ' Dim ErrorFlag As Integer ErrorFlag = MyList.ReadRecord(rec) ' Read the class record If ErrorFlag = 0 Then Me.Text = "Random Access Files Record: " & CStr(rec) ' Set the title txtFirst.Text = MyList.First txtLast.Text = MyList.Last txtAddr.Text = MyList.Addr txtCity.Text = MyList.City txtState.Text = MyList.State txtZip.Text = MyList.Zip Else MessageBox.Show("Read failure") End If End Sub Private Sub btnRead_Click(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles btnRead.Click Dim response As String response = InputBox("Enter the record number:", "Valid Record Numbers 1 _ through " & CStr(MyList.CurrentRecordCount)) FillARecord(CLng(response)) End Sub
If we click the First button, the btnFirst code sets the CurrentRecord to 1 and calls FillARecord() , which uses the clsList ReadRecord() method to read the data. If you look at the code for the remaining buttons, the same general methodology is used. The CurrentRecord number is adjusted according to the button that was clicked and FillARecord() is called to read and display the data.
One last feature is the Read button. When the user clicks on this button, an InputBox() dialog box pops up and asks the user to enter the record number that he wants to search for. The first argument is a prompt string, and the second argument is the title for the dialog box. This sequence is shown in Figure 23.10.
Figure 23.10. Using an InputBox() dialog box to request a record number.
When the user clicks the OK button on the InputBox() , the data typed in by the user is returned as a string. We then convert this string data into a Long for use as the record number to display using a call to FillARecord() .
We didn't add an Edit button to the form, although you could do this easily enough. All you would need to do is call FillARecord() to move the edited data into the clsList members and then call WriteRecord() using the original record number.