Back to the basics - Reading and writing files
Up until now, we've used the keyboard and the screen to input some data and display some values. This is a common form of input and output, or I/O. Another common practice is reading and writing data to files. Storing data in files allows you to persist data over time. This saves the user from inputting th same data over and over again.
In this chapter we'll build upon the main.rs and utils.rs from the previous chapter. We'll ask the user for
some input, save it to a file. Then we'll read the content back and print it on the screen. Don't worry, we'll
do this in baby steps.
Adding I/O capabilities to our project
At the top of our main.rs file there is this 'use' statement: use crate::utils::ask_for_a_number;. This statement
allows us to use the ask_for_a_number function from the utils module. ask_for_a_number is a function you've
written
yourself. Rust also provides a whole library of functions developed by the Rust community; the standard library.
You can use function from the standard library by adding a use std:: statement to the source file. In our case
we'll add this line to main.rs: use std::fs::write;
Adding this line allows us to use the write function from the fs (filesystem) module in the standard library.
Showing documentation for a function
In RustRover you can view instructions on the use of a function, by pressing and holding the "cmd" button on your
keyboard
and hovering the mouse over the function in the use statement. The function will become underlined, like a link
on a web page. You can click the link to open a new tab with the documentation (and the source code of the function).
Ignore the source mumbo jumbo and focus on the documentation directly over the function. In the newer versions of
RustRover this is rendered in a nicely formatted way.
Exercise
Use the above method to highlight the
writefunction, click on it, and read the documentation. You can clone thefs.rstab afterwards.
We've seen that write can be used to write contents to a file. Exactly what we need!
The complete Rust standard library is documented here: doc.rust-lang.org
Getting some data from the user.
We'll use the Person structure from the Data structures chapter to hold the user input.
Add the code above the main() function, and include an age field:
struct Person {
first_name: String,
last_name: String,
age: u8,
}
Now we'll ask the user to provide some input to fill this structure:
main.rs
use crate::utils::{ask_for_a_number, ask_for_a_string};
use std::fs::write;
mod utils;
struct Person {
first_name: String,
last_name: String,
age: u8,
}
fn main() {
let first_name = ask_for_a_string("What is your first name?");
let last_name = ask_for_a_string("What is your last name?");
let age = ask_for_a_number("How old are you?").unwrap_or(0);
let person = Person {
first_name,
last_name,
age,
};
}
For simplicity’s sake I've used the
.unwrap_or(0)to fall back to a default age of0when the user makes an input error.
Exercise
Run the above code and see if you can input first name, last name and age.
Now we'll write the contents of Person to a file named people.txt. For this we'll create a new
function: write_person:
fn write_person(person: &Person) -> std::io::Result<()> {
let mut output = String::new();
output.push_str(&person.first_name);
output.push('\n');
output.push_str(&person.last_name);
output.push('\n');
output.push_str(&person.age.to_string());
output.push('\n');
write("people.txt", output)
}
Unfortunately the Person data structure cannot be written to a file as-is. We can however write a String to a
file. The write_person function creates a new empty String called output and appends the fields of person to
the output.
The push_str method adds the contents of the provided field to output.
The push method adds a single character to output.
The
'\n'represents the new line character. By pushing this to theoutput, we effectively add an "Enter" after the field's content has been added. We do this so that we can read the file later in an easy way.
Finally we use the write function to write the output to the people.txt file.
The std::io::Result<()> that we are returning, is actually the output of the write function. We are returning this
transparently to the main function. The std::io::Result Is similar to the Result type we've explored
earlier. It either holds an Ok(()) or an std::io::Error. We can use the Error type to
notify the user if something bad happened when writing the people.txt file to the hard drive.
The
()in thestd::io::Result<()>is Rust speak for "nothing". So this means that in the positive case, when we returnOk(()), we are actually returning nothing to the calling function, other than the fact that the operation was a success.
Now that we've created the write_person function, we can add it to the bottom of the main() function, immediately
after creating the person variable:
write_person(&person);
Exercise
Run the program, input the needed fields and wait for the program to finish. Now check the top-level project directory and look for the
people.txtfile. Open it by double-clicking. Explore the contents.
If all went well, you should have a people.txt file that holds the data you've inputted. Pretty neat, don't you think?
Exercise
Use the instructions from the previous chapter to commit your code. Use the message: "write Person to disk", or something similar.
Checking for errors
If your program compiled, and ran, but the people.txt file was not created. This chapter is for you!
We've seen that the write function can possibly return an Error as part of the Result. We've ignored this error
for now. Let's add some code to the main.rs to write a message to the user in case of a failure.
Exercise
Add a
matchblock for the call towrite_person(&person)in themain()function. Do this by replacing thewrite_person(&person);line with:match write_person(&person) { }Notice the red line under
match. Click on it and wait for the red light-bulb to appear. Click on the light-bulb and select "Add remaining patterns". See what happens.
I've added some feedback to the match block. main.rs now looks like this:
use crate::utils::{ask_for_a_number, ask_for_a_string};
use std::fs::write;
mod utils;
struct Person {
first_name: String,
last_name: String,
age: u8,
}
fn main() {
let first_name = ask_for_a_string("What is your first name?");
let last_name = ask_for_a_string("What is your last name?");
let age = ask_for_a_number("How old are you?").unwrap_or(0);
let person = Person {
first_name,
last_name,
age,
};
match write_person(&person) {
Ok(_) => println!("people.txt was written successfully"),
Err(err) => println!("There was error while writing people.txt: {}", err),
}
}
fn write_person(person: &Person) -> std::io::Result<()> {
let mut output = String::new();
output.push_str(&person.first_name);
output.push('\n');
output.push_str(&person.last_name);
output.push('\n');
output.push_str(&person.age.to_string());
output.push('\n');
write("people.txt", output)
}
The
_in theOk(_)statement means that we want to ignore the data that was wrapped in theOk(). As seen before thewrite_personfunction is returning()in anOkwhich means: "nothing". So there is no need for us to create a variable for this. It is empty anyway.
Exercise
Run the program again and watch for any errors.
Reading the data
You can take a break here if you want. Make sure to commit your changes, before you leave!
Now that we've written the people.txt file we'll use a new function to read the Person back from the file. You've
guessed it... read_person.
To read something from a file, we need to use a new function from the standard library: read_to_string.
Replace the use std::fs::write line at the top of main.rs with this:
use std::fs::{write, read_to_string};
Now add the read_person function:
fn read_person() -> Result<Person, std::io::Error> {
let input = read_to_string("people.txt")?;
let mut lines = input.split('\n');
let first_name = lines.next().unwrap_or("").to_string();
let last_name = lines.next().unwrap_or("").to_string();
let age_as_string = lines.next().unwrap_or("0").to_string();
let age = u8::from_str(&age_as_string).unwrap_or(0);
let person = Person {
first_name,
last_name,
age: age,
};
Ok(person)
}
Exercise
Notice that the
from_stris highlighted in red. Use the same "light-bulb" method from before, to " Import" the missing 'use' statement.
The read_person function does exactly the opposite to the write_person function.
It starts with the read_to_string("people.txt") to read the contents of the people.txt file to the input variable.
Notice the
?at the end of the line. The?is a super powerful feature of Rust. It will check the result of the preceding method. If there is an error, it will exit out of the function, retuning the error. Otherwise, it will assign the value in theOkto the variable. So the?is like a match block in disguise.
We know for sure that input will hold the contents of the people.txt file. In case of an error, due to the ?,
the read_person function would have already stopped and returned the error.
Next we use the split('\n') method on input.
Do you recognize the
\ncharacter? This matches up with the separator we've used when creating thepeople.txtfile. Because we were so clever before, we can now easily re-create the input fields with thesplitmethod.
The next three lines read the split values, using an empty string """ in case something went wrong.
Recall the use of
unwrap_orfrom the earlier chapter.
Now our age field needs a special treatment. We read the age_as_string which holds the text-version of the age
field. We use the same from_str associated function, from before, to convert the String to a u8.
Finally, we create a new Person structure from these fields, and return this wrapped in an Ok.
Let's add the read_person function to main() and see if it works.
The main.rs should look like this:
use crate::utils::{ask_for_a_number, ask_for_a_string};
use std::fs::{read_to_string, write};
use std::str::FromStr;
mod utils;
struct Person {
first_name: String,
last_name: String,
age: u8,
}
fn main() {
let first_name = ask_for_a_string("What is your first name?");
let last_name = ask_for_a_string("What is your last name?");
let age = ask_for_a_number("How old are you?").unwrap_or(0);
let person = Person {
first_name,
last_name,
age,
};
match write_person(&person) {
Ok(_) => println!("people.txt was written successfully"),
Err(err) => println!("There was error while writing people.txt: {}", err),
}
match read_person() {
Ok(person) => println!("people.txt was read successfully"),
Err(err) => println!("There was error while reading people.txt: {}", err),
}
}
fn write_person(person: &Person) -> std::io::Result<()> {
let mut output = String::new();
output.push_str(&person.first_name);
output.push('\n');
output.push_str(&person.last_name);
output.push('\n');
output.push_str(&person.age.to_string());
output.push('\n');
write("people.txt", output)
}
fn read_person() -> Result<Person, std::io::Error> {
let input = read_to_string("people.txt")?;
let mut lines = input.split('\n');
let first_name = lines.next().unwrap_or("").to_string();
let last_name = lines.next().unwrap_or("").to_string();
let age_as_string = lines.next().unwrap_or("0").to_string();
let age = u8::from_str(&age_as_string).unwrap_or(0);
let person = Person {
first_name,
last_name,
age: age,
};
Ok(person)
}
Printing the Person
There is one thing left to do; print the Person back to the user.
Exercise
Use the knowledge from the previous chapters to add a new
print()method toPersonthat prints the three fields back to the user.
Modify the last match block to look like this:
match read_person() {
Ok(person) => {
println!("people.txt was read successfully:");
person.print();
},
Err(err) => println!("There was error while reading people.txt: {}", err),
}
Run the program and see how your print method works.
Congratulations! Another major milestone achieved!
Cleaning up
Now that our program is functional, let's take some time to clean it up. We'll start by creating an associated
function to construct the Person:
impl Person {
fn new() -> Self {
let first_name = ask_for_a_string("What is your first name?");
let last_name = ask_for_a_string("What is your last name?");
let age = ask_for_a_number("How old are you?").unwrap_or(0);
Person {
first_name,
last_name,
age,
}
}
}
I'd also like to change write_person and read_person into write_people and read_people:
The write_people function is a pretty straightforward change from write_person:
fn write_people(people: Vec<Person>) -> std::io::Result<()> {
let mut output = String::new();
for person in people {
output.push_str(&person.first_name);
output.push('\n');
output.push_str(&person.last_name);
output.push('\n');
output.push_str(&person.age.to_string());
output.push('\n');
}
write("people.txt", output)
}
For read_people I've split the code between read_person and read_people:
fn read_person(lines: &mut Split<char>) -> Option<Person> {
let first_name = lines.next()?;
let last_name = lines.next()?;
let age_as_string = lines.next()?;
let age = u8::from_str(&age_as_string).unwrap_or(0);
let person = Person {
first_name: first_name.to_string(),
last_name: last_name.to_string(),
age,
};
Some(person)
}
fn read_people() -> Result<Vec<Person>, std::io::Error> {
let input = read_to_string("people.txt")?;
let mut lines = input.split('\n');
let mut people = vec![];
while let Some(person) = read_person(&mut lines) {
people.push(person)
}
Ok(people)
}
The read_people is the function that will be called from main. It now returns a list of Person, a Vec<Person>.
The while let is similar to the if let, we've seen in previous chapters, but while let loops like for until the
condition becomes false. In this case, it will add a person to the people vector, until the read_person function
returns None.
I've modified the read_person function, and added a few question marks (?). The ? ensures that None is returned
when there is no more data that can be read. This typically means, we've reached the end of the file.
The main function is adjusted to use the new functions:
fn main() {
let person = Person::new();
match write_people(vec![person]) {
Ok(_) => println!("people.txt was written successfully"),
Err(err) => println!("There was error while writing people.txt: {}", err),
}
match read_people() {
Ok(_people) => println!("people.txt was read successfully"),
Err(err) => println!("There was error while reading people.txt: {}", err),
}
}
Exercise
Now that all the needed functions are defined, please move all functions, except
mainto theutils.rs. Also move thestruct Personandimpl Person. Usepubto make functions, fields and structs, accessible bymain.rs. Make sure the code compiles. You can haveclippycheck this for you.
Exercise
If you want to make your code really nice, create a new
db.rsfile, and more the people-related code in that file, rather than intoutils.rs.
Validate that your code matches with: this.
Now that the project is nice and tidy, we're ready to move to the next chapter!