Turtles all the way down

This post demonstrates a process for designing functions that work together.

It introduces the turtle crate. The crate allows you to create images using turtle graphics. You won’t be able to use it in the rust playground on play.rust-lang.org, you need to run it on your own computer.

If you have already installed Rust on your computer, you should be able to run the examples. Otherwise, now is a good time to install.

Follow the instructions on the Rust Installation page.

If you don’t feel comfortable with that method, here is another page with other ways to install rustup. Rustup is the preferred tool to manage Rust installations.

You can check out the Turtle crate on its homepage, and look at the documentation. Click the big green “Let’s get started” button to reach the documentation.

The turtle crate

We will be using an external crate in this post. That will require that you have Rust installed on your own computer. It will also require you to create project files, and adjust some settings to get things working.

When you install Rust, you also get a tool called cargo. You’ll be using cargo a lot during the rest of your Rust coding career. One of the things you use it for is to create new projects from the command line so that is what we’ll start with.

I recommend that you use the terminal in Linux or macOS, and git-bash if you run on Windows. You get git-bash when you install Git, the best modern tool for version control.

Start by creating a folder to hold all your coding projects, and navigate to it in your terminal of choice. Once you get there, create a new project with cargo new --bin polygon && cd polygon.

Now you have a new Rust project. You can run it with cargo run, and it will compile and run the code that is provided for you as a template. You will see this as a result:

Compiling polygon v0.1.0 (/path-to-your-folder/polygon)
    Finished dev [unoptimized + debuginfo] target(s) in 1.23s
     Running `target/debug/polygon`
Hello, world!

If you see something else, something has gone wrong. Check that you have followed the instructions or start over.

Now, to be able to use an external crate, we need to tell cargo that we want it, so that we can refer to it later in our code. First we change the project settings. They are in a file named Cargo.toml inside the polygon directory.

Before we do that, it’s important to figure out exactly what we need to do. We should check the documentation for the crate. There are two ways we can do this. We can go to the crates own homepage (always a good idea), or the more common way, via a site called crates.io.

We’ll use crates.io, so go there first, and enter turtle in the search field. Then click on the entry turtle in the search results, it should be at the top of the list.

Now you get to the crates own page at crates.io. There you will see a field that says “Cargo.toml” beside a black rectangle with text in it. The text inside the black rectangle is the text we need to add to the dependencies section in our Cargo.toml. Copy that text, and open Cargo.toml in your code editor.

Scroll down to the line where it says [dependencies]. Add a line beneath it and paste what you copied from the web page:

[dependencies]
turtle = "1.0.0-rc.3"

Your numbers may be different, but 1.0.0-rc.3 was the most current version when I wrote this.

Save Cargo.toml and close it. We won’t be needing to change more than this.

The line in Cargo.toml will tell cargo that we need the crate turtle, and we want the version inside the quotes. Cargo will download and install the dependencies on the next cargo run.

Now, replace the code in src/main.rs with the following code:

use turtle::Turtle;

fn main() {
    let mut turtle = Turtle::new();
}

The first line tells Rust that we are going to use the crate turtle. To be precise, we want an object inside turtle called Turtle. The :: is a delimiter like the / is in file paths in Linux and macOS. You’ll be seeing it quite often in Rust programs.

The crate turtle (with a lowercase ’t’) provides an object called Turtle (with an uppercase ’T’). We assign that object to a variable named turtle on the first line inside the function main.

Once you create a Turtle, you can call methods on it to move it around the window. A method is like a function, but it uses different syntax.

The method,forward(), is connected to the turtle object we’re calling turtle. Calling a method is like making a request: you are asking turtle to move forward.

The argument of forward() is a distance in pixels, so the actual size depends on your display. Other methods you can call on a Turtle are backward() to move backward, left() for left turn, and right() for right turn. The argument for left() and right() is an angle in degrees.

Also, each Turtle is holding a pen, which is either down or up; if the pen is down, the Turtle leaves a trail when it moves. The methods pen_up and pen_down stand for “pen up” and “pen down”. To draw a right angle, add these lines to the program (after creating turtle ):

    turtle.forward(100.0);
    turtle.left(90.0);
    turtle.forward(100.0);

The whole program should look like this:

use turtle::Turtle;

fn main() {
    let mut turtle = Turtle::new();
    turtle.forward(100.0);
    turtle.left(90.0);
    turtle.forward(100.0);
}

When you run this program, you should see a turtle move north and then west, leaving two line segments behind. The program won’t stop on its own, even if the turtle does, to stop the program you have to close the window yourself.

Now change the program to draw a square. Don’t go on until you’ve got it working!

Simple repetition

Chances are you wrote something like this:

use turtle::Turtle;

fn main() {
    let mut turtle = Turtle::new();
    turtle.forward(100.0);
    turtle.left(90.0);
    turtle.forward(100.0);
    turtle.left(90.0);
    turtle.forward(100.0);
    turtle.left(90.0);
    turtle.forward(100.0);
}

We can do the same thing more concisely with a loop, that we can create with a for statement. like this:

use turtle::Turtle;

fn main() {
    let mut turtle = Turtle::new();
    for i in 1..5 {
        turtle.forward(100.0);
        turtle.left(90.0);
    }
}

Now run the program again, and you should see the turtle draw a square.

The syntax of a for statement is like a function declaration. It has a header that ends with a { and an indented body. The body can contain any number of statements. A for statement is also called a loop. The flow of execution runs through the body and then loops back to the top. In this case, it runs the body four times.

Wait, four times? It says 1..5 in the code, so what’s going on here?

When we use ranges like 1..n the 1 in the beginning it includes the first number, but not the last. That means that if we write 1..4, we’ll get 1, 2, 3.

There’s also the matter of i, what’s that doing there?

In many for loops we use the range we’re looping over to extract a value and do something with that value. So we bind it to a variable, and the most common name of that variable is i as short for index.

In this loop it is actually not needed at all, because we’re not using it inside the loop. How do we avoid creating a variable we don’t need then?

In Rust, we can use the “variable name” _ to tell Rust we don’t care about the value, and we don’t want to assign it to a real variable. Let’s do that:

use turtle::Turtle;

fn main() {
    let mut turtle = Turtle::new();
    for _ in 1..5 {
        turtle.forward(100.0);
        turtle.left(90.0);
    }
}

This version is actually a little different from the previous square-drawing code. It makes another turn after drawing the last side of the square. The extra turn takes more time, but it simplifies the code if we do the same thing every time through the loop. This also leaves the turtle in the same position it started, facing in the starting direction.

A few exercises

The following is a series of exercises using turtles. They are meant to be fun, but they have a point, too. While you are working on them, think about what the point is.

The following sections have solutions to the exercises, so don’t look until you have finished (or at least tried).

  1. Write a function called square that takes a parameter named t, which is a turtle. It should use the turtle to draw a square.\ \ Write a function call that passes turtle as an argument to square(), and then run the program again. Check the error messages and follow the hints given were it says help:, if the program doesn’t work.

  2. Add another parameter, named length, to square. Change the body so the length of the sides is length. Then change the function call to provide a second argument. Run the program again. Test your program with a range of values for length.

  3. Make a copy of square and change the name of the copy to polygon. Add another parameter named n and change the body so it draws an n-sided regular polygon. Hint: The exterior angles of an n-sided regular polygon are 360/ n degrees.

  4. Write a function called circle that takes a turtle,t, and radius,r, as parameters. Make it draw an approximate circle by calling polygon with an appropriate length and number of sides. Test your function with a range of values of . Hint: figure out the circumference of the circle and make sure that length * n = circumference.

  5. Make a more general version of circle called arc that takes an extra parameter angle. The angle determines what fraction of a circle to draw. angle is in units of degrees, so when angle = 360, arc should draw a complete circle.

Encapsulation

The first exercise asks you to put your square-drawing code into a function declaration. Then call the function, with the turtle as a parameter. Here is a solution:

use turtle::Turtle;

fn main() {
    let turtle = Turtle::new();
    square(turtle);
}

fn square(mut t: Turtle) {
    for _ in 1..5 {
        t.forward(100.0);
        t.left(90.0);
    }
}

A couple of comments here: first, we need to make t mutable, so that we can change it. The original, turtle doesn’t need to be mutable, since no mutation takes place in main.

We indent the innermost statements twice to show that they are inside the loop. The loop in turn is inside the function.

Inside the function, t refers to the same turtle turtle, so t.left(90.0) has the same effect as turtle.left(90.0) has outside the function. In that case, why not call the parameter turtle? The idea is that t can be any turtle, not just our turtle. You could create a second turtle and pass it as an argument to square:

let mut alice = Turtle::new();
square(alice);

Wrapping a piece of code up in a function is called encapsulation. One of the benefits of encapsulation is that it attaches a name to the code. The name then serves as a kind of documentation. Another advantage is that if you reuse the code, it is easier to call a function twice than to copy and paste the body!

Generalization

The next step is to add a length parameter to square. Here is a solution:

use turtle::Turtle;

fn main() {
    let turtle = Turtle::new();
    square(turtle, 100.0);
}

fn square(mut t: Turtle, length: f64) {
    for _ in 1..5 {
        t.forward(length);
        t.left(90.0);
    }
}

Adding a parameter to a function is called generalization. It makes the function more general. In the previous version, the square is always the same size; in this version it can be any size.

The next step is also a generalization. Instead of drawing squares, polygon draws regular polygons with any number of sides. Here is a solution:

fn polygon(t: &mut Turtle, length: f64, n: usize) {
    let angle: f64 = 360.0 / (n as f64);
    for _ in 1..(n + 1) {
        t.forward(length);
        t.left(angle);
    }
}

We’ve changed things a bit here. First of all, we changed the t to be a borrowed mutable Turtle, by changing its type to &mut Turtle. We do this, because we don’t need to own it, and the caller might want to use it again for something else. We just borrow it for a while, mutate it and give it back when we’re done.

We’ve also set the number of sides to an integer. It seems silly to use a float to tell how many sides something has. What does 3.5 sides look like anyway? To make the angle correct, we need to change the number we get from the division 360/n into an f64. That’s what the turtle wants for its angle.

To do that, we change 360 to 360.0 to let Rust know we want a floating point number here. Then we tell Rust that we want the n to be converted to an f64, to get the result as a f64.

We could try to use (360/n) as f64, but the division will automatically be truncated to an integer first. That would leave a gap at the end of the drawing.

In the loop we do a weird thing. We don’t care about the index, and instead of assigning it to i as we would normally do, we tell Rust to throw it away. We do that by setting the “variable name” to _. The symbol _ basically means “whatever…”, we don’t care what this is.

This example draws a square, lifts the pen, and moves right 150 pixels. It then puts the pen down again and draws a 7-sided polygon with side length 70:

use turtle::Turtle;

fn main() {
    let mut turtle = Turtle::new();
    square(&mut turtle, 100.0);
    turtle.pen_up();
    turtle.right(90.0);
    turtle.forward(150.0);
    turtle.pen_down();
    polygon(&mut turtle, 70.0, 7);
}

fn square(t: &mut Turtle, length: f64) {
    for _ in 1..5 {
        t.forward(length);
        t.left(90.0);
    }
}

fn polygon(t: &mut Turtle, length: f64, n: usize) {
    let angle: f64 = 360.0 / (n as f64);
    for _ in 1..(n + 1) {
        t.forward(length);
        t.left(angle);
    }
}

In this version, we need the original turtle to be mutable from the start, because we mutate it in main too. Like lifting its pen, moving it and putting the pen back down.

We’ve also updated the square function to look a lot like polygon. We borrow a mutable Turtle, mutate it and hand it back to the caller, so that the caller can continue to use it.

Interface design

The next step is to write circle, which takes a radius,r, as a parameter. Here is a simple solution that uses polygon to draw a 50-sided polygon:

fn circle(t: &mut Turtle, r: f64) {
    let circumference: f64 = std::f64::consts::PI * 2.0 * r;
    let length: f64 = circumference / 50.0;
    polygon(t, length, 50);
}

The first line computes the circumference of a circle with radius r using the formula 2 * pi * r.

I can hear you complaining already, “You cheated, you brought in a crate we’ve never heard of, and you’re not even using extern crate std to bring it in!”.

Nope. The crate std is always included, and you can always use it whenever you want. You might want to glance at the documentation for it. There’s a lot of useful stuff in there, that you’ll use over and over.

Documentation for the Rust Standard Library.

Let’s continue with the example now: n is the number of line segments in our approximation of a circle. So length is the length of each segment. Thus, polygon draws a 50-sided polygon that approximates a circle with radius r.

One limitation of this solution is that n is a constant. This means that for very big circles, the line segments are too long. For small circles, we waste time drawing very small segments. One solution would be to generalize the function by taking n as a parameter. This would give the user (whoever calls circle) more control, but the interface would be less clean.

The interface of a function is a summary of how it is used: what are the parameters? What does the function do? And what is the return value? An interface is “clean” if it allows the caller to do what they want, without dealing with unnecessary details.

In this example, r belongs in the interface. It specifies the circle to be drawn. But n is less appropriate because it refers to the details of how the circle should be rendered. It is better to choose an appropriate value of n, depending on circumference:

let n: usize = ((circumference / 3.0) as usize) + 3;

Now the number of segments is an integer near circumference/3. So the length of each segment is approximately 3. Small enough that the circles look good, but big enough to be efficient. And acceptable for any size circle. Adding 3 to n guarantees that the polygon has at least 3 sides.

Notice also that we wrote as usize after the parentheses. We need that because the result of (circumference / 3.0) is a f64. And we can’t use a floating point for n, as we’ve already declared it to be a usize on the left side of the =.

This is an important thing to remember and use later, that we can change the type of a value by using as followed by the name of the type we want. It doesn’t always work with all types, but with numbers it does.

Refactoring

When I wrote circle, I was able to reuse polygon because a many-sided polygon is a good approximation of a circle. But arc is not as cooperative; we can’t use polygon or circle to draw an arc.

One alternative is to start with a copy of polygon and transform it into arc. The result might look like this:

fn arc(t: &mut Turtle, r: f64, angle: f64) {
    let arc_length = 2.0 * std::f64::consts::PI * r * angle.abs() / 360.0;
    let n = arc_length / 4.0 + 3.0;
    let step_length = arc_length / n;
    let step_angle = angle / n;
    for _ in 1..(n as usize + 1) {
        t.forward(step_length);
        t.left(step_angle);
    }
}

There is a special treatment of angle above, because we use the type f64, and those numbers can be negative. But we don’t want a negative angle, so we use the absolute value of the angle. And we can get that through a method on f64 called abs().

This works, and the second half of this function looks like polygon. But we can’t reuse polygon without changing the interface. We could generalize polygon to take an angle as a third argument. But then polygon would no longer be an appropriate name! Instead, let’s create a more general function and call it polyline:

fn polyline(t: &mut Turtle, n: usize, length: f64, angle: f64) {
    for _ in 1..(n + 1) {
        t.forward(length);
        t.left(angle);
    }
}

Now we can rewrite polygon and arc to use polyline:

fn polygon(t: &mut Turtle, length: f64, n: usize) {
    let angle: f64 = 360.0 / (n as f64);
    polyline(t, n, length, angle);
}
fn arc(t: &mut Turtle, r: f64, angle: f64) {
    let arc_length = 2.0 * std::f64::consts::PI * r * angle.abs() / 360.0;
    let n = arc_length / 4.0 + 3.0;
    let step_length = arc_length / n;
    let step_angle = angle / n;
    t.left(step_angle / 2.0);
    polyline(t, n as usize, step_length, step_angle);
}

Finally, we can rewrite circle to use arc:

fn circle(t: &mut Turtle, r: f64) {
    arc(t, r, 360.0);
}

This process is called refactoring. We rearrange a program to improve interfaces and re-use code, . In this case, we noticed that there was similar code in arc and polygon, so we “factored it out” into polyline.

If we had planned ahead, we could have written polyline first and avoided refactoring. But often you don’t know enough at the beginning of a project to design all the interfaces. Once you start coding, you understand the problem better. Sometimes refactoring is a sign that you have learned something.

A development plan

A development plan is a process for writing programs. The process we used in this case study is “encapsulation and generalization”. The steps of this process are:

  1. Start by writing a small program with no function declarations.

  2. Once you get the program working, identify a coherent piece of it. Encapsulate the piece in a function and give it a name.

  3. Generalize the function by adding appropriate parameters.

  4. Repeat steps 1–3 until you have a set of working functions. Copy and paste working code to avoid retyping (and re-debugging).

  5. Look for opportunities to improve the program by refactoring. If you have similar code in several places, consider factoring it into a more general function.

This process has some drawbacks. We will see alternatives later, but it can be useful if you don’t know ahead of time how to divide the program into functions. This approach lets you design as you go along.

Doc comments

A doc comment is a string at the beginning of a function that explains the interface. The “doc” is short for “documentation”. Here is an example:

fn polyline(t: &mut turtle, n: usize, length: usize, angle: usize) {
    /// Takes a turtle t, and draws n line segments
    /// with the given length and angle in
    /// degrees between them.

All doc comments need triple-slashes at the beginning of the line. When you run cargo doc in your projects root folder, rustdoc will generate documentation for you. It will place it in target/doc.

It is terse, but it contains the essential information someone would need to use this function. It explains what the function does (without getting into the details of how it does it). It explains what effect each parameter has on the behaviour of the function. And what type each parameter should be (if it is not obvious).

Writing this kind of documentation is an important part of interface design. A well designed interface should be simple to explain. If you have a hard time explaining one of your functions, you need to improve the interface.

Debugging

An interface is like a contract between a function and a caller. The caller agrees to provide certain parameters. And the function agrees to do certain work.

For example,polyline requires four arguments: t has to be a borrowed, mutable Turtle. The parameter n has to be an integer. The parameter length should be a positive floating point number. And angle has to be a floating point number in degrees.

We call these requirements preconditions. They must be true before the function starts executing. Conditions that need to be true at the end of the function are postconditions. Postconditions include the intended effect of the function (like drawing line segments). And any side effects (like moving the Turtle or making other changes).

Preconditions are the responsibility of the caller. If the caller violates a (well documented!) precondition and the function doesn’t work, the bug is in the caller, not the function.

If the preconditions are true and the postconditions are not, the bug is in the function. If your pre- and postconditions are clear, they can help with debugging.

Testing

There’s another thing preconditions and postconditions can do for you. They can help you create automated tests for your functions. If you know the range of acceptable preconditions and input to a function. And you know the postconditions you want as results, you can test that those conditions are met.

We’ll get more into testing later, when we explore Test Driven Development, or TDD for short.

Right now, we’ll just look at how we can combine documentation comments and tests, and we’ll use a simple function as an example. Let’s create a function that adds two integers:

fn add(a: isize, b: isize) -> isize {
    a + b
}

How do we test this? Well, first we need to establish that it works when preconditions are met. We could test that if we give it two isize arguments, it returns an isize value that is equal to the sum of the arguments.

To our help, we have a macro by the name of assert_eq!, that we can give our function and the expected result, like so:

assert_eq!(add(1,2),3);

This will panic if the assertion fails.

Ok, so where do we put this test then?

Tests can go in several places, but one of the most common is at the bottom of the same file as the functions they are testing:

fn main() {
    println!("{}", add(1, 2));
}
/// Takes two signed integers (isize) and returns their
/// sum as isize

fn add(a: isize, b: isize) -> isize {
    a + b
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_add() {
        assert_eq!(add(1, 2), 3);
    }
}

First we declare a new module, called test, with the lines

#[cfg(test)]
mod tests {
// code and stuff
}

This separates the test code from the code we want to use in our production version of the application. We don’t want that to be compiled into the final application. We’re just using it to test that our code works before we build the final version and ship it to our paying customers.

Then we create a function to test our function, and we add the line #[test] just above it. This is an attribute we are giving to the function, that marks it as a test, so that cargo can pick it up and run it.

Once that is done, all we need to do is to run the test, with the help of cargo. In the terminal we give the command cargo test, and things immediately go wrong:

error[E0425]: cannot find function `add` in this scope
  --> src/main.rs:18:20
   |
18 |         assert_eq!(add(1, 2), 3);
   |                    ^^^ not found in this scope
   |
help: consider importing this function
   |
17 |     use crate::add;
   |

error: aborting due to previous error

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

What’s going on? We can clearly see that the function add exists, and it’s even in the same file, so why can’t the compiler find it?

Remember we said that we created a new module for the tests? Well, Rust can’t reach code outside that module without us explicitly allowing it to. We do that by telling it that it is ok to use the rest of the code in the parent module, in this case this file. How? By inserting the line use super::*; on the first line of the module. Our file now looks like this:

fn main() {
    println!("{}", add(1, 2));
}
/// Takes two signed integers (isize) and returns their
/// sum as isize
fn add(a: isize, b: isize) -> isize {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn test_add() {
        assert_eq!(add(1, 2), 3);
    }
}

When we run the tests with cargo again, the compiler and test runner is much happier:

  Compiling addtest v0.1.0 (/Users/maxelander/rust_apprentice/addtest)
    Finished test [unoptimized + debuginfo] target(s) in 0.48s
     Running target/debug/deps/addtest-50fba5ba8f62149f

running 1 test
test tests::test_add ... ok

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

We’ll do more testing later, and we’ll also get into adding tests to the documentation comments.

Exercise: write a failing test, and see what happens. Create a new test function after the first. Remember to give it the attribute #[test] on the line above the function header.

Then write an assertion that should fail, and run the tests.

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!