DEV Community

Cover image for 30 Days of Rust - Day 19
johnnylarner
johnnylarner

Posted on

30 Days of Rust - Day 19

Hi everyone,
I'm writing from Istanbul today. Turkey is gripped by Sunday's presidential elections. Around 88% of eligible voters casted their votes. That level is participation in democracy is unheard of in many western countries!

Anyway, back to business: today's blog it going to cover how to structure Rust projects. We'll look about how cargo provides an opinionated default project setup as well as how to use your code across your project.

Yesterday's questions answered

No questions to answer

Today's open questions

No open questions

What exactly is a crate again?

Earlier in this blog series, I touched briefly on the subject of crates. At first glance, a crate can appear analogous to a package (particularly when coming from a Python context). Yet the Rust book offers us a more precise definition of a crate:

AΒ crateΒ is the smallest amount of code that the Rust compiler considers at a time.

What turns a crate into a package is the presence of a Cargo.toml file. Packages can typically be divided into two categories: binary executables or libraries. Some important things to note here are:

  • A package can contain multiple binary executables
  • A package can contain up to one library executable
  • A package may consist of both binary executables and a library

One interesting design pattern recommended by the book is to include both a library and executable when designing your package. This can help you as a developer to better understand and develop your code's public API: you are both the publisher and consumer of the API.

You may ask: how does cargo know when you're creating an executable as opposed to a library? The answer lies in the name of the file generated by these two commands:

cargo new my_executable
ls my_executable/src # => main.rs
Enter fullscreen mode Exit fullscreen mode
cargo new my_lib --lib
ls my_lib/src # => lib.rs
Enter fullscreen mode Exit fullscreen mode

Whether your project's root is main.rs or lib.rs is decisive for the compiler. Now let's look at how we can start adding code to our package.

Packages as file systems

We've seen that packages in Rust can have types of root files. The root is the starting point of the compiler's search for your code. From the root, we have to tell the compiler what other code we'll need to compile. This is done through declaring a module using the mod keyword:

// main.rs
mod cool_mod;
fn main() {}
Enter fullscreen mode Exit fullscreen mode

As per the Rust book, this will tell the compiler to look in three places:

  • Inline (i.e. in the curly brace block which follows)
  • In src/cool_mod.rs
  • Or src/cool_mod/mod.rs (this is old-school and should be avoided)

If we want to declare modules in any file other than the root, these can be referred to as submodules.

The similarity between the hierarchies of a package in Rust and a file system are pretty clear to see. Remember that a module in Rust, however, does not strictly have to be organised in separate files. For small projects, declaring modules inline in your root may suffice.

Keep your code private

The code snippet above wouldn't really be of much use to a developer as the module is private. This means the code inside the module cannot be accessed from outside of the scope of that module's block.

By making code private by default, Rust makes the developer actively consider how much of their code should be visible. In theory this should lead to better code design. By the way you can make code public by adding pub before its declaration (as a function, module, struct, etc...). Structs declared public will still require fields and methods to also be public if they are to be accessed by an external caller. When an enum is declared public, however, all variants of an enum will become visible.

Making in-paths

Once public, you can import code using an absolute or relative path. Absolute paths start with crate:: whereas a relative path begins with either self:: , super:: or the current module's identifier.

Bigger projects may have deeply nested project structures; and if the project uses absolute paths, then you may end up with very long lines merely to call functions declared deep in a submodule in your crate root. The use keyword is a solution to this problem. When you use a path, the last part of that path will come into scope:

let result = my_module::my_sub_module::my_sub_sub_module::my_func();
// OR
use my_module::my_sub_module::my_sub_sub_module;
let result = my_sub_sub_module::my_func();
Enter fullscreen mode Exit fullscreen mode

Similarly, you can use the as keyword to designate an import alias to something. This is particularly common when importing Error or Result from different packages:

use std::fmt::Result; 
use std::io::Result as IoResult;
Enter fullscreen mode Exit fullscreen mode

Finally, if you are working with a deeply nested library, you may want to re-export an import path. This can be particularly useful when the library design doesn't match the common understanding of the packages domain:

pub use my_module::my_sub_module::my_sub_sub_module;

pub fn some_high_level_func() { my_sub_sub_module::some_func(); }
Enter fullscreen mode Exit fullscreen mode

Top comments (0)