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
write
function, click on it, and read the documentation. You can clone thefs.rs
tab 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 of0
when 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.txt
file. 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
match
block 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_person
function is returning()
in anOk
which 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_str
is 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 theOk
to 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
\n
character? This matches up with the separator we've used when creating thepeople.txt
file. Because we were so clever before, we can now easily re-create the input fields with thesplit
method.
The next three lines read the split values, using an empty string """
in case something went wrong.
Recall the use of
unwrap_or
from 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 toPerson
that 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
main
to theutils.rs
. Also move thestruct Person
andimpl Person
. Usepub
to make functions, fields and structs, accessible bymain.rs
. Make sure the code compiles. You can haveclippy
check this for you.
Exercise
If you want to make your code really nice, create a new
db.rs
file, 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!