Back to the basics - processing input

In the previous chapter, we've looked at functions. In this chapter, we'll add a function to read some input from the user of our program, and do some basic processing on it.

fn main() {
    println!("What is your name?");
    let input = read_string();
    println!("Your name is: {input}");
}

fn read_string() -> String {
    let mut input = String::new();
    std::io::stdin()
        .read_line(&mut input)
        .expect("can not read user input");
    input
}

This example prints the line: What is your name?, waits for the user to type a name and hit the [Enter] button. Then prints Your name is: followed by the user's name.

We're using the read_string() function to read the user's input. Let's explore that function.

We see that that read_string returns a String datatype. A String is a convenient wrapper around the str data type, we've used in the previous chapters. Unlike str it can be mutated which is exactly what we need in our function.

Before we can read anything, we need to create a place to store the user's input. The String::new() function creates a new String in memory, i.e., the 'drawer' that can hold a String type. We use the mut keyword to indicate that we'll be changing the String later on. The :: separator is used to indicate that we'll be calling a function that belongs to the preceding item. In this case we call the new() function that belongs to the String datatype.

A function that belongs to a datatype is called an associated function. Because the new() function is associated with String, Rust knows we are creating a new String.

The next line actually reads a line of text from the "standard input". stdin is short for "standard input". The standard input is usually the keyboard.

We're using Rust's standard library std::io to take care of the actual reading. io stands for input & output.

The dot . chains together a sequence of operations. In our case:

  1. std::io::stdin() to get access to the keyboard
  2. read_line(&mut input) to read a string of text and store the result in input
  3. expect("can not read user input") to write an error in case something prevents us from reading (no keyboard??)

It does not matter if you write all these operations on a single line, or separate them over several lines. The ; at the end determines the end of the statement, not the line break.

Notice that the read_line function takes &mut input as the input parameter. You've learned that the & is used to lend an item to a function. Similar to let vs let mut, we can indicate whether our variable can be changed by the function or not. In the cabinet analogy, whether the contents of the drawer may be replaced by the function or not.

The &mut keyword indicates that the read_line function can modify the contents of the input variable, i.e. the drawer labelled with input.

Finally the contents of input is returned at the end of the read_string function. Notice that there is no ; at the end of the input statement. Omitting the ; at the end of input tells Rust to return the content of that variable as a result of the function. It is shorthand for return input; Which would essentially do the same.

I can see how the above is quite a lot to take in. Let's take some of these new concepts and dig into them a bit. We'll revisit our example later.

Strings, associated functions and methods

In the above example, we've seen that we can create a new String with String::new(). You've learned that the :: separator is used to call an associated function; a function that belongs to the String type. There are more associated functions available on the String type. RustRover editor will actually show what functions you can use when you pause typing after the :: separator. There is the from() associated function to create a new String filled with a piece of text.

fn main() {
    let my_name = String::from("Marcel");
    println!("Your name is: {my_name}");
}

After we've created a String we can call certain functions that belong to the String object, not the type. These are called "methods". A methods is called with the dot '.' separator, rather than the '::' separator. Here's an example:

fn main() {
    let my_name = String::from("Marcel");
    let my_name_in_lower_case = my_name.to_lowercase();
    println!("Your name is: {my_name_in_lower_case}");
}

So what is the difference between an associated function and a method?

I'll try to explain it with the same cabinet analogy we've used thus far.

Think of an associated function as an instruction that comes with the cabinet and describes how to construct the drawer. The instruction is related (or associated) with the drawer, but only tells you how to build the drawer. It does not describe how to manipulate the item that is stored in the drawer.

A method is an instruction that belongs to the finished drawer. It tells Rust how to manipulate an item that is stored in that specific drawer.

In our case the to_lowercase() method takes the item from the my_name drawer and converts all the characters to lower case. We are storing the result of the to_lowercase() operation in a new drawer called: my_name_in_lower_case.

Typically associated functions are used to create a new instance of a particular type. Methods are used to manipulate the particular type after it has been created.

Let's look at another String method:

fn main() {
    let bad_name = String::from("Marcil");
    let fixed_name = bad_name.replace("i", "e");
    println!("Your name is: {}", fixed_name);
}

Exercise

Call a method on a newly created String that converts the text to upper case and print the result.

Clippy

Now that our code becomes a bit more complex, we're likely going to run into some coding mistakes, or bugs. Rust comes with a 'friend' that can help identify these mistakes and who often offers some suggestions on how they can be remediated. This 'friend' is called: Clippy.

You can ask Clippy to inspect your program by:

  1. Double-tap the 'ctrl' key on your keyboard to open the "run anything" popup window.
  2. Type cargo clippy and press the Enter key

Inspect the output window

If it looks like this, you're good:

/Users/marcel/.cargo/bin/cargo clippy --color=always
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s

Process finished with exit code 0

You can use the down arrow key (⬇️) on your keyboard after opening the "run anything" window, on subsequent runs to quickly select cargo clippy from the list of previous commands.

Do not ignore the warning given by Clippy!

These warnings often point to a programming mistake or a missed opportunity for optimization.

Processing input

Back to our original example:

fn main() {
    println!("What is your name?");
    let input = read_string();
    println!("Your name is: {}", input);
}

fn read_string() -> String {
    let mut input = String::new();
    std::io::stdin()
        .read_line(&mut input)
        .expect("can not read user input");
    input
}

There is a method on String that strips any empty characters and new-line breaks from a piece of text. It is called trim().

Exercise

Modify the above example to clean the input with the trim() method and print the result. Try the example by adding a number of spaces to the beginning of your name when typing. Remember that you can run the program with the play (▶️) symbol in front of fn main().

Exercise

Now modify your code to include a new function: read_clean_string, that reads the input, cleans it with trim(), and then returns it. You need to call the to_string() method on the cleaned result to convert it to a String. You can check the spoiler if you get stuck.

Congratulations! By completing the above exercise, you've created a fully functional program that reads some data, does some manipulation on the data and outputs it (to the screen). You will see that pretty much all software programs follow this exact paradigm.

In the next chapters we'll build on this concept, so make sure you have good understanding on variables and functions, before moving on.