All functions return values

Many of the functions we have used, produce return values. But the functions we’ve written all seem to be void. They have an effect, like printing a value or moving a turtle, but they don’t seem to have a return value. In this post you will learn to write functions that have explicit return values. And what those that seem to to not return anything actually do return.

Return values

The functions we have written so far all seem to be void. They have no return value; or rather, their return value is (), which we call the unity value.

In this post, we are (finally) going to write functions that have explicit return values. The first example is area, which returns the area of a circle with the given radius:

fn area(radius: f32) -> f32 {
    area = 3.1416 * radius * radius;
    return area;
}

We have seen the return statement in other functions before. Here the statement includes an expression. This statement means: “Return immediately from this function and use the following expression as a return value.”

The expression can be as complicated as you need it to be. We could have written this function like this:

fn area(radius: f32) -> f32 {
    return 3.1416 * radius * radius;
}

But temporary variables like area can make debugging easier.

We could also take advantage of the fact that there is a module in std called f32. It has many constants and methods defined on it. We could substitute the value for π with a constant. Then use the powf() method from the primitive type f32 to simplify further:

use std::f32::consts;

fn area(radius: f32) -> f32 {
    return consts::PI * radius.powf(2.0);
}

In fact, we can do even more to simplify! We can leave an expression on the last line of a function. Then we leave off the semicolon at the end. And the result of that expression will be the functions return value, like so:

fn area(radius: f32) -> f32 {
    consts::PI * radius.powf(2.0)
}

The one thing we cannot leave out though, is the declaration of the return value in the header. We must tell the compiler what we intend to return from the function. That makes it possible for the compiler to check our code for errors. Then it can make sure that we get the type right in the return value. This is especially helpful in the next type of functions.

Sometimes it is useful to have many return statements, one in each branch of a conditional:

fn absolute_value(x: f32) -> f32 {
    if x < 0.0 {
        return -x;
    } else {
        return x;
    }
}

Since these return statements are in an alternative conditional, only one runs.

We can simplify this, like we did above. We don’t need the else clause, since if x ≥ 0 we always want to return x:

fn absolute_value(x: f32) -> f32 {
    if x < 0.0 {
        return -x;
    }
    return x;
}

And also, since return x; is the last line in the function, we don’t need the return keyword nor the ;, so we can write:

fn absolute_value(x: f32) -> f32 {
    if x < 0.0 {
        return -x;
    }
    x
}

As soon as a return statement runs, the function terminates. It doesn’t execute any of the statements that follow the return. We call code that sits after a return statement, or any other place the flow of execution can never reach, dead code.

In a function, it is a good idea to ensure that every possible path through the program hits a return statement. For example:

fn absolute_value(x: f32) -> f32 {
    if x < 0.0 {
        return -x;
    }
    if x > 0.0 {
        return x;
    }
}

This function is incorrect. If x happens to be 0, neither condition is true. So the function ends without hitting an explicit return statement. If the flow of execution gets to the end of a function, the return value is (), which is not the absolute value of 0. And the compiler wastes no time telling us so:

error[E0317]: `if` may be missing an `else` clause
  --> src/main.rs:13:5
   |
9  |   fn absolute_value(x: f32) -> f32 {
   |                                --- expected `f32` because of this return type
...
13 | /     if x> 0.0 {
14 | |         return x;
15 | |     }
   | |_____^ expected `f32`, found `()`
   |
   = note: `if` expressions without `else` evaluate to `()`
   = help: consider adding an `else` block that evaluates to the expected type

error: aborting due to previous error

For more information about this error, try `rustc --explain E0317`.
error: could not compile `slask`.

To learn more, run the command again with --verbose.

Here we learn something more, from reading the error message. Even if statements have a return type! They return (), which is also what this function tries to return if we feed it the value 0 for x. We now see the error of our ways, and can fix the problem:

fn absolute_value(x: f32) -> f32 {
    if x < 0.0 {
        return -x;
    }
    if x >= 0.0 {
        return x;
    }
}

Although I like the shorter version better, and the compiler even accepts this:

fn absolute_value(x: f32) -> f32 {
    if x < 0.0 { return -x }
    x
}

Despite the missing semi-colon after return -x! But that is because the code block ends with the }. So the compiler knows that the expression has ended.

By the way, all signed primitive number types provide a built-in function called abs(). It computes absolute values, so we don’t have to do it ourselves.

As an exercise, you can write a function named compare that takes two values, x and y, and returns 1 if x > y, 0 if x == y, and -1 if x < y.

Test Driven Development

As you write larger functions, you might find yourself spending more time debugging. There is a process called Test Driven Development, or TDD, you might want to try. It is a way to help deal with more and more complex programs. The goal of TDD is to avoid long debugging sessions by adding and testing only a small amount of code at a time.

As an example, suppose you want to find the distance between two points. You have the coordinates ( x1 , y1 ) and ( x2 , y2 ) . The Pythagorean theorem gives the distance:

distance = (( x2 - x1 )2 + ( y2 - y1)2)0.5

The first step is to consider what a distance function should look like in Rust. In other words, what are the inputs (parameters) and what is the output (return value)?

In this case, the inputs are two points, which you can represent using four numbers. The return value is the distance represented by a floating-point value.

We can write a test for a function that we call distance, and then we run it and watch it fail:

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn test_distance() {
        assert_eq!(distance(0.0, 0.0, 3.0, 4.0), 5.0);
    }

When we run this, we get this result:

error[E0425]: cannot find function `distance` in this scope
  --> src/main.rs:10:20
   |
10 |         assert_eq!(distance(0, 0, 3, 4), 5);
   |                    ^^^^^^^^ not found in this scope

warning: unused import: `super::*`
 --> src/main.rs:7:9
  |
7 |     use super::*;
  |         ^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

error: aborting due to previous error; 1 warning emitted

The compiler complains that the function distance doesn’t exist. We can fix that, we can write an outline of the function:

fn distance(x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
    0.0
}

We test it again, and this time we get a different error. After a heap of warnings about unused variables that I’ll leave out, we get this:

    Finished test [unoptimized + debuginfo] target(s) in 0.51s
     Running target/debug/deps/tddc6-7216da7ad4cd71a9

running 1 test
test tests::test_distance ... FAILED

failures:

---- tests::test_distance stdout ----
thread 'tests::test_distance' panicked at 'assertion failed: `(left == right)`
  left: `0.0`,
 right: `5.0`', src/main.rs:14:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::test_distance

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--bin tddc6'

The test failed, because we didn’t get a return value of 5.0, we got 0.0.

That’s easy to fix, we exchange the 0.0 in our function for 5.0:

fn distance(x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
    5.0
}

Running the test now produces this, if we skip all the warnings about unused variables:

    Finished test [unoptimized + debuginfo] target(s) in 0.34s
     Running target/debug/deps/tddc6-7216da7ad4cd71a9

running 1 test
test tests::test_distance ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Great, we passed the test.

Now, this version doesn’t compute distances; it always returns 5.0. But it has the correct syntax, and it compiles and runs. That means that you can test it before you make it more complicated.

The compiler gives a lot of warnings about unused variables, but we already know about them. And warnings are not as bad as errors.

At this point we have confirmed that the function has the correct syntax, and we can start adding code to the body. A reasonable next step is to find the differences

x2 - x1
and
x2 - x1
.

The next version stores those values in temporary variables and prints them.

fn distance(x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
    let dx = x2 - x1;
    let dy = y2 - y1;
    println!("dx is {}.", dx);
    println!("dy is {}.", dy);
    5.0
}

If we run this, and the function is working, it should display dx is 3. and dy is 4.. If so, we know that the function is getting the right arguments. We also know it performs the first computation the right way. If not, there are only a few lines to check.

Next we compute the sum of squares of dx and dy:

fn distance(x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
    let dx = x2 - x1;
    let dy = y2 - y1;
    let dsquared = dx.powf(2.0) + dy.powf(2.0);
    println!("dsquared is {}.", dsquared);
    5.0
}

Again, you would run the program at this stage and check the output (which should be 25). Finally, you can use sqrt() which exists as a method on the primitive type of f32 to compute and return the result:

fn distance(x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
    let dx = x2 - x1;
    let dy = y2 - y1;
    let dsquared = dx.powf(2.0) + dy.powf(2.0);
    dsquared.sqrt()
}

The final version of the function doesn’t display anything when it runs; it only returns a value. The println!() statements we wrote are useful for debugging. Once you get the function working, you should remove them. We call that kind of code scaffolding. It is helpful for building the program but is not part of the final product.

Now we can write more tests, with other numbers to see if the function actually works as intended:

fn main() {
    let _my_distance = distance(0.0, 0.0, 3.0, 4.0);
}

fn distance(x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
    let dx = x2 - x1;
    let dy = y2 - y1;
    let dsquared = dx.powf(2.0) + dy.powf(2.0);
    dsquared.sqrt()
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn test_distance() {
        assert_eq!(distance(0.0, 0.0, 3.0, 4.0), 5.0);
    }

    #[test]
    fn test_distance2() {
        assert_eq!(distance(1.0, 2.0, 4.0, 6.0), 5.0);
    }
}

If we run cargo test, we get this result:

running 2 tests
test tests::test_distance ... ok
test tests::test_distance2 ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

When you start out, you should add only a line or two of code at a time. As you get more experience, you’ll be writing and debugging bigger chunks. Either way, incremental development can save you a lot of debugging time. The key aspects of the process are:

  1. Start with a test that fails.
  2. Create a working function that satisfies the test. Even if the function doesn’t make sense.
  3. Make small incremental changes. At any point, if there is an error, you should have a good idea where it is.
  4. Use variables to hold intermediate values so you can display and check them.
  5. Once the program is working, you remove some of the scaffolding. Try turning separate statements into compound expressions. But only if it does not make the function difficult to read.

As an exercise, use TDD to write a function called hypotenuse. It should return the length of the hypotenuse of a right triangle. The arguments should be the lengths of the other two sides. Record each stage of the development process as you go.

Composition

As you should expect by now, you can call one function from within another. As an example, we’ll write a function that takes two points. The first point is the center of the circle. The second is a point on the perimeter. The function computes the area of the circle.

We store that the center point in the variables xc and yc, and the perimeter point in xp and yp. The first step is to find the radius of the circle, which is the distance between the two points. We wrote a function, distance, earlier that does that:

fn distance(x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
    let dx = x2 - x1;
    let dy = y2 - y1;
    let dsquared = dx.powf(2.0) + dy.powf(2.0);
    dsquared.sqrt()
}

The next step is to find the area of a circle with that radius; we wrote that, too earlier:

fn area(radius: f32) -> f32 {
    consts::PI * radius.powf(2.0)
}

Encapsulating these steps in a function, we get:

fn circle_area(xc: f32, yc: f32, xp: f32, yp: f32) -> f32 {
   let radius: f32 = distance(xc, yc, xp, yp);
   let result: f32 = area(radius);
   return result;
}

The temporary variables radius and result are useful for development and debugging. Once the program is working, we can make it more concise by composing the function calls:

fn circle_area(xc: f32, yc: f32, xp: f32, yp: f32) -> f32 {
    area(distance(xc, yc, xp, yp))
}

Boolean functions

Functions can return booleans. This is often convenient for hiding complicated tests inside functions. For example:

fn is_divisible(x: f32, y: f32) -> bool {
    if (x%y) == 0.0 {
        return true;
    }
    false
}

It is common to give boolean functions names that sound like yes/no questions. Like is_divisible, that returns either true or false to tell whether x is divisible by y.

The result of the == operator is a boolean. This means we can write a shorter function with an implicit return:

fn is_divisible(x: f32, y: f32) -> bool {
    (x%y) == 0.0
}

Boolean functions are often used in conditional statements:

if is_divisible(x,y) {
    println!("x is divisible by y");
}

It might be tempting to write something like:

if is_divisible(x,y) == true {
    println!("x is divisible by y");
}

But the extra comparison is unnecessary. Never compare boolean values against true or false, use them as they are instead. Otherwise you will make a mistake sooner rather than later.

As an exercise, write a function is_between(x: f32, y: f32, z: f32) that returns true if x ≤ y ≤ z and false otherwise.

More recursion

We have only covered a small subset of Rust so far. But this subset is a complete programming language. This means that this language is enough to express anything that can anyone can compute. You can rewrite any program ever written, with the language features you have learned so far. You would need a few commands to control devices like the mouse, hard drives, etc., but that’s all.

Proving that claim is a nontrivial exercise. It was first accomplished by Alonzo Church, in his λ-calculus (Lambda-caculus). Alan Turing did the same a short time later. It looked like Turing was using another method, but later research showed that it was the same. These where two of the first computer scientists. Some would argue that they were mathematicians. But a lot of early computer scientists started as mathematicians). We call this the Church-Turing Thesis. They both relied on a definition by Kurt Gödel, about what he called general recursive functions. If you want to know more about this, I recommend Michael Sipser ’s book Introduction to the Theory of Computation.

To give you an idea of what you can do with the tools you have learned so far, let us examine a few mathematical functions. These happen to be recursive definitions. A recursive definition is like a circular definition. The definition contains a reference to the thing it defines. A truly circular definition is not very useful:

vorpal: An adjective used to describe something that is vorpal.

A dictionary definition like that is’t very helpful. In mathematics, we denote the factorial function with !. If you looked up the definition of the factorial function you might get something like this:

0! = 1
n! = n( n - 1 )!

This definition says that the factorial of 0 is 1, and the factorial of any other value, n , is n multiplied by the factorial of (n - 1).

So 3! is 3 times 2!, which is 2 times 1!, which is 1 times 0!. Putting it all together, 3! equals 3 times 2 times 1 times 1, which is 6.

If you can write a recursive definition of something, you can write a Rust program to calculate it. The first step is to decide what the parameters should be. In this case it should be clear that factorial takes a positive integer, and returns a number.

What number type should we choose? At first glance you would think it should be an integer of some kind. But, factorials grow very fast. In this case it’s best to choose a 64-bit floating point number, as that allows for higher values than u64. The max value of u64 is 18 446 744 073 709 551 615, whereas f64 doesn’t have a max value.

fn factorial(n: u32) -> f64 {}

If the argument happens to be 0, all we have to do is return 1:

fn factorial(n: u32) -> f64{
    if n == 0 { return 1.0 }
}

Otherwise we have to make a recursive call to find the factorial of n-1 and then multiply it by n :

fn factorial(n: u32) -> f64{
    if n == 0 { return 1.0 }
    n * factorial(n-1.0)
}

But this doesn’t work, the compiler complains:

error[E0277]: cannot multiply `f64` to `u32`
  --> src/main.rs:47:7
   |
47 |     n * factorial(n-1)
   |       ^ no implementation for `u32 * f64`
   |
   = help: the trait `std::ops::Mul<f64>` is not implemented for `u32`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.

Bummer. So what can we do? Well, we can cheat. Rust knows how to convert from integer to float, and vice versa. In this case, we want to switch type for n, to f64, so let’s try this:

fn factorial(n: u32) -> f64{
    if n == 0 { return 1.0 }
    n as f64 * factorial(n-1)
}

And that compiles! Yay!

The flow of execution for this program looks like the flow of countdown in the post about conditionals and recursion. If we call factorial with the value 3:

Since 3 is not 0, we take the second branch and calculate the factorial of 3-1:

	Since 2 is not 0, we take the second branch and calculate the factorial of 2-1:

		Since 1 is not 0, we take the second branch and calculate the factorial of 1-1:

			Since 0 equals 0, we take the first branch and return 1 without making any more recursive calls:

		The return value is 1, we multiply it by 1 , which is 1, and return the result:

	The return value, 1, we multiply it by 2 , which is 2, and return the result:

We multiply the return value (2) by 3 , which is 6, and return it. This becomes the return value of the function call that started the whole process.

Wishful thinking

Following the flow of execution is one way to read programs. It can quickly become overwhelming. An alternative is what I call “wishful thinking”. When you reach a function call, you wish the function works and returns the right result.

In fact, you are already practicing this when you use built-in functions. When you call any of them, you don’t examine the bodies of those functions. You think they work because the people who wrote them were good programmers.

The same is true when you call one of your own functions. For example, earlier in this post, we wrote a function called is_divisible. It determines whether one number is divisible by another. We have convinced ourselves that it is correct by examining the code and testing. Now we can use it without looking at the body again.

The same is true of recursive programs. When you get to the recursive call, instead of following the flow of execution, you should think that it works. And that it returns the correct result. Then ask yourself, “Assuming that I can find the factorial of n - 1, can I compute the factorial of n?” It is clear that you can, by multiplying by n.

It’s a bit strange to think that the function works when you haven’t finished writing it. But that’s why it’s called wishful thinking!

One more example

The Fibonacci function is the second most common example of recursive functions. After factorial. It has the following definition:

fibonacci(0) = 0
fibonacci(1) = 1
fibonacci(n) = fibonacci(n - 1) + fibonacci (n - 2)

Translated into Rust, it looks like this:

fn fibonacci(n: i32) -> u64 {
    if n == 0 {
        return 0;
    } else if n == 1 {
        return 1;
    }
    return fibonacci(n-1) + fibonacci(n-2);
}

If you try to follow the flow of execution here, even for small values of n, your head explodes. But with the method of “programming by wishful thinking”, things become easier. If you beleive the two recursive calls work, then it is clear that you get the right result by adding them together.

Debugging

Breaking a large program into smaller functions creates natural checkpoints for debugging. If a function is not working, there are three possibilities to consider:

  • The arguments the function is getting are wrong; you’re vioalating a precondition.
  • There is something wrong with the function; you’re violating a postcondition.
  • There is something wrong with the return value or the way it is being used.

To rule out the first possibility, you can add a println! statement at the beginning of the function. Display the values of the parameters (and their types if you want to). Or you can write test code that checks the preconditions.

If the parameters look good, add a println! statement before each return statement. This lets you display the return value. If possible, check the result by hand. Consider calling the function with values that make it easy to check the result. Like we did with the hypotenuse earlier.

If the function seems to be working, look at the function call to make sure you use the return value in the correct way (or at all!).

You can make the flow of execution more visible by adding print statements.

About the author

For the last three decades, I've worked with a variety of technologies. I'm currently focused on fullstack development. On my day to day job, I'm a senior teacher and course developer at a higher vocational school in Malmoe, Sweden. I'm also an occasional tech speaker and a mentor. Do you want to know more? Visit my website!