Sökresultat för

Cargo – konvention framför konfiguration

50 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
Cargo – konvention framför konfiguration

Hur många gånger har du inte öppnat ett nytt C++ projekt och först behövt lista ut om det byggs med make, CMake, Bazel eller ett handskrivet skript — och sen vilket av Conan, vcpkg eller FetchContent som sköter tredjepartsbiblioteken? I Rust-världen finns inte den frågan. Ett enda verktyg, cargo, skapar projektet, kompilerar det, kör testerna, genererar dokumentationen och hämtar hem beroenden — med samma kommandon, i alla projekt, från dag ett. I den här artikeln går jag igenom hur cargo fått till detta, varifrån idén kommer, och avslutar med ett litet demo-program som lånar in ett par tredjepartsbibliotek för att rita en Normal-fördelning som ett stjärn-histogram.


Kompilering av Rust

Med bara en enda källkodsfil hello.rs är det enkelt att kompilera till en exekverbar fil.

fn main() {
    println!("Hello from a Rust program");
}
$ rustc hello.rs -o hello-rs
$ ./hello-rs
Hello from a Rust program

Principen är ju den samma som för en C++ källkodsfil hello.cxx.

#include <print>
int main() {
    std::println("Hello from a C++ program");
}
$ g++ -std=c++23 -Wall hello.cxx -o hello-cxx
$ ./hello-cxx
Hello from a C++ program

Blir det *.o filer också i Rust?

Javisst, det fungerar på samma sätt. Normalt genererar kompilatorn dessa i en temporär-katalog och tar bort dessa vid ett lyckosamt resultat. I Rust kan man be kompilatorn om att spara *.o filen på följande vis.

$ rustc --emit=obj,link hello.rs -o hello-rs
$ ls -lhF *-rs*
-rwxrwxr-x 1 jensr jensr 4.2M Sep  3 16:37 hello-rs*
-rw-rw-r-- 1 jensr jensr 5.6K Sep  3 16:37 hello-rs.o
$ nm --demangle hello-rs.o
0000000000000000 r GCC_except_table8
0000000000000000 T main
0000000000000000 T std::rt::lang_start::<()>
0000000000000000 t std::sys::backtrace::__rust_begin_short_backtrace::<fn(), ()>
0000000000000000 t std::rt::lang_start::<()>::{closure#0}
0000000000000000 T hello::main
                 U std::rt::lang_start_internal
                 U std::io::stdio::_print
# Stripped for brievity

För C++ ser ut det ut på liknande sätt, fast man behöver utföra det i två steg. Först kompileringen och sedan länkningen.

$ g++ -std=c++23 -Wall -c hello.cxx -o hello-cxx.o
$ g++ hello-cxx.o -o hello-cxx
$ ls -lhF *-cxx*
-rwxrwxr-x 1 jensr jensr 241K Sep  3 16:44 hello-cxx*
-rw-rw-r-- 1 jensr jensr 458K Sep  3 16:42 hello-cxx.o
$ nm --demangle hello-cxx.o
...812 lines of output...

Byggnationsverktyg

Så fort man har fler än några få källkodsfiler blir det snabbt besvärligt att hålla reda på olika kompileringskommandon. I UNIX barndom (första hälften av 1970-talet) var shell-scripts ett viktigt hjälpmedel. Ett dylikt är en textfil med vilka kommandon som ska utföras och operativet (kernel) känner igen ett skript och utför instruktionerna.

Med ett flertal källkodsfiler och olika typer av genereringar med flera, blev det besvärligt även med shell-scripts, eftersom man gärna önskade att bara kompilera om de filer som behövdes.

Stuart Feldman vid Bell Labs (det legendariska stället som frambringat UNIX, C, C++ och mycket annat) skrev den första versionen av make, där man kunde skapa kompileringsregler och verktyget beräknade vilka filer som behövde omkompileras och i vilken ordningsföljd. Under flera decennier var make det primära byggverktyget, även för Java under andra hälften av 1990-talet.

För just Java-världen —i takt med att språket blev allt populärare— blev det pinsamt att man inte kunde bygga plattformsoberoende, eftersom det var ju själva språkets USP (Unique Selling Point). Direkt efter millennieskiftet skapade James Duncan Davidson Java-verktyget Ant (Another Neat Tool), där man skapade byggregler i en XML-fil.

I takt med att Java blev ännu mer populärt och det skapades en kopiös mängd av open-source bibliotek, uppstod behovet av en rationell och enkel hantering av primära bibliotek och alla deras sekundära bibliotek (beroenden). Några år senare (2002) skapade Jason van Zyl den första versionen av Maven, vilket snabbt kom att ersätta Ant. Maven byggde på några genomgripande principer:

  • Configuration by Conventions
    • En standardstruktur för projektkatalogen, så hittar alla verktyg källkodsfilerna
  • Standard set of commands
    • Standardiserade kommandon för att kompilera, testa, bygga, skapa dokumentation
  • Dependency Management & Maven-Central
    • Via en s.k. maven-koordinat (grupp, namn, version) kunde verktyget självt ladda ned bibliotek och inkorporera dessa för byggnation och exekvering
  • Plugins
    • Maven core är en plugin-motor med de väsentliga reglerna inbyggda, resten utgörs av plugins, vilka kan kompletteras eller ersättas och möjliggör obegränsat med utökningar

Det här med att skapa en central plats för bibliotek (server) och ett kommandoradsprogram, som söker, laddar ned, installerar och inkorporerar bibliotek, tog Jason från CPAN.

Under 1990-talet i takt med att språket Perl blev populärt uppstod behovet av en dylik tjänst. CPAN (Comprehensive Perl Archive Network) skapades i början på 1990-talet och bidrog starkt till att göra Perl industriellt tillämpbart. Man brukade skämta om att nästa krasch på Wall Street kunde bero på en bugg i ett Perl program.

CPAN var emellertid inte först med detta utan inom TeX-världen (typsättning för matematisk text) skapades några år innan, CTAN (Comprehensive TeX Archive Network). CPAN har fått många efterföljare inklusive CEAN (Comprehensive Erlang Archive Network) som Joe Armstrong vid Ericsson/Ellemtell i Älvsjö satte upp i slutet på 1990-talet.

Sedan CPAN och Maven Central, så har alla moderna programmeringsspråk en motsvarighet, dels en gemensam server med sökbarhet och en massa bibliotek och dels ett CLI program som sköter hanteringen på utvecklarsidan.

Emellertid, är det synd och skam att vi inte har motsvarande inom C++ världen. Det finns dessvärre flera konkurrerande system, såsom Conan, vcpkg med flera. Även CMake har sin egen FetchContent. Detta sammantaget gör att ingen hittills kunnat fungera enande, utan vi har en diversifierad soppa av olika byggverktyg.


Cargo

Rust är tack och lov ett modernt programmeringsspråk och har därför den typ av supportsystem man numera förväntar sig. Byggverktyget för Rust heter cargo och täcker i stort sett hela arbetsflödet runt ett Rust-projekt:

  • projektskaparecargo new sätter upp mappstruktur, manifest och ett git-repo
  • pakethanterarecargo add hämtar tredjepartsbibliotek från registret crates.io och håller reda på exakta versioner
  • byggverktygcargo build kompilerar projektet och allt det beror på, i rätt ordning, parallellt
  • testkörarecargo test hittar och kör alla tester i projektet
  • dokumentationsgeneratorcargo doc bygger en HTML-sajt ur doc-kommentarerna
  • publiceringsverktygcargo publish laddar upp en crate till crates.io (utanför den här artikelns ämne)

Poängen är att allt detta följer med. Installerar man Rust via rustup får man rustc (kompilatorn) och cargo i samma svep, och cargo är i praktiken det enda kommando man behöver lära sig.

Så här skapar men ett nytt Rust-projekt:

$ cargo new hello
    Creating binary (application) `hello` package

Resultatet är minimalt:

hello
├── Cargo.toml        # projektets manifest
├── .gitignore        # innehåller raden /target
└── src
    └── main.rs       # fn main() { println!("Hello, world!"); }

Cargo initierar dessutom ett git-repo på köpet. Med option --vcs <name> kan man initiera för andra versionshanteringssystem (VCS - Version Control System), där name kan vara ett av git, hg, pijul, fossil, none. Den sistnämnda innebär inget av dem.

Designen av cargo har inspirerats av Jason van Zyl's princip om configuration by conventions. All källkod ligger under katalogen src/ och filnamnet main.rs är fixerat och styr att cargo ska bygga en exekverbar fil. Direktiven till cargo är standardiserade likväl som i Maven, cargo build|test|run|.... Alla byggartefakter hamnar i katalogen target/, precis som i Maven.

Konfiguration (Cargo.toml)

Cargo har en primär konfigurationsfil (plus en för avancerade byggen) kallad Cargo.toml och är på formatet TOML (Tom's Obvious, Minimal Language). Detta format skapades av Tom Preston-Werner och är en renodlad och standardiserad form av INI-filer från den gamla MS-DOS tiden. Så här kan en Cargo.toml se ut:

[package]
name = "hello"
version = "0.1.0"
edition = "2024"

[dependencies]

Här ser vi namn och version på projektet, samt vilken språk-version projektet använder sig av. edition är Rusts sätt att införa språkändringar utan att bryta gammal kod. Giltiga värden är 2015, 2018, 2021 eller 2024. Edition-värdet har viss likhet med -std=c++23 i C++, men är mer genomgripande.

Lådprojekt (crate)

Ett Cargo-projekt benämnes som crate. Ordet betyder låda på svenska (faktiskt även ölback, men det tiger vi om). Vi ser också att det finns ett tomt avsnitt för externa bibliotek (dependencies), dvs external dependency crates. Detta ska vi diskutera litet senare i denna artikel.


Programbyggnation

Man kan kompilera och länka separat först (cargo build) och sedan exekvera (cargo run), eller låta den sistnämnda utföra det förstnämnda.

$ cargo run
   Compiling hello v0.1.0 (/home/jensr/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.12s
     Running `target/debug/hello`
Hello from a Rust program

Bibliotek

En crate kan också definiera ett bibliotek, dvs en crate som länkas till andra crates och bildar ett program (exekverbar fil). Genom att byta namn på src/main.rs till src/lib.rs, så hux flux har man ett biblioteks-projekt (library crate). Som synes, styr filnamnet huruvida ens crate utgör en executable eller library.

my_project
├── Cargo.toml
└── src/
    └── lib.rs

Måste man välja? Nej, en crate kan ha båda formerna src/{main,lib}.rs. Själva filnamnet är dock fixerat. Givetvis kan man dela upp källkoden på flera filer, så kallade moduler, vilket vi ska diskutera härnäst.


Moduler

I ett C++ program delar man upp koden i flera filer (.cxx), kompilerar dessa separat till objekt-filer (.o) och skriver inkluderings-filer (*.hxx) innehållande typ-definitioner och funktions-deklarationer, så att kompilatorn och länkaren kan para ihop ett anrop av en funktion med var denna är definierad. Med C++20 kom modules, som är tänkt att förenkla denna hantering och korta byggtiderna. Emellertid, är detta, ett drygt halvt decennium senare, fortfarande på experimentstadiet.

Bakom kulisserna i Rust försiggår ungefär samma operationer, men språket har haft stöd för moduler redan från första början. Även här följer man configuration by conventions, genom att skapa en underkatalog med modul-namnet och en fil med det standardiserade namnet mod.rs.

my_project
├── Cargo.toml
└── src/
    ├── main.rs
    └── numbers/
        └── mod.rs

Sedan, måste källkodsfilen på samma katalognivå deklarera modulen via mod <name>.

mod numbers;
fn main() { . . . }

Det finns en principiell och en pragmatisk sida av detta. Beträffande det sistnämnda så klagade utvecklare på att ha en bunt med filer med samma namn (mod.rs) öppna i sin utvecklingsmiljö (IDE). Så från edition 2018 och rustc 1.30, infördes en förenkling; man skapar kort och gott en Rust fil med modul-namnet, på samma nivå som den som definierar modulen. Så här kan det se ut då:

my_project
├── Cargo.toml
└── src/
    ├── main.rs
    └── numbers.rs

För mindre moduler, räknat i antal rader kod, kan man placera koden direkt i den fil som deklarerar denna. Detta kallas för en inline module.

fn main() {
    println!("origo: {:?}", geometry::origin());
}

mod geometry {                                                                              
    #[derive(Debug)]
    pub struct Point { pub x: f64, pub y: f64 }                                             
    pub fn origin() -> Point { Point { x: 0.0, y: 0.0 } }                                   
} 

Rust använder sig frekvent av annoteringar med syntaxen #[action(key=value...)]. En vanlig annotering är cfg(...) som står för configuration. Ett sådant exempel är plattforms-beroende definitioner:

#[cfg(target_os = "linux")]
mod platform {  
  pub fn config_dir() -> String {  
      std::env::var("XDG_CONFIG_HOME")  
          .unwrap_or_else(|_| format!("{}/.config", std::env::var("HOME").unwrap()))  
  }  
  pub const LINE_ENDING: &str = "\n";  
}  

#[cfg(target_os = "windows")]  
mod platform {  
  pub fn config_dir() -> String {  
      std::env::var("APPDATA").unwrap()  
  }  
  pub const LINE_ENDING: &str = "\r\n";  
}  

#[cfg(target_os = "macos")]  
mod platform {  
  pub fn config_dir() -> String {  
      format!("{}/Library/Application Support", std::env::var("HOME").unwrap())  
  }  
  pub const LINE_ENDING: &str = "\n";  
}  

fn main() {  
  println!("config: {}", platform::config_dir());  
  print!("done{}", platform::LINE_ENDING);  
}

Här ovan har vi också villkorlig kompilering (hejsan #ifdef), bara en av dessa tre alternativ stannar kvar, resten plockas bort. Givet då att man inte försöker kompilera på en fjärde plattform... 🫣

Annoteringarna ovan är inte på något sätt begränsade till inline modules, utan kan appliceras på vanliga fil-baserade moduler också.


Multipla program

För en crate gäller att man kan definiera högst ett bibliotek, men noll, ett eller flera program. Vi har ju sett att ett program definieras med src/main.rs, så hur skapar man övriga? Jo, genom att placera dessa under src/bin/. Om man så vill kan man betrakta main.rs som det primära programmet och övriga i src/bin/ som sekundära. Emellertid, kanske man inte har något program som är primärt, eftersom alla är likvärdiga. Då är det bättre att lägga samtliga program under src/bin/ för symmetrins skull.

my_project
├── Cargo.toml
└── src/
    ├── lib.rs
    ├── numbers.rs
    └── bin/
        ├── generate.rs
        └── print.rs

Hur anropar man då ett av flera definierade program? Jo, genom att bifoga namnet.

$ cargo run --bin generate
$ cargo run --bin print

Nu kan det vara så att man önskar att ett av dessa program ska kunna anropas utan att ange dess namn, vilket låter sig göras i konfigurationsfilen med default-run.

# Cargo.toml
[package]
name = "hello"
default-run = "print"
. . .

Vidare, kanske man anser att namnet på den genererade exekverbara filen ska vara något annat, vilket också kan åtgärdas i konfigurationsfilen med ett (eller flera) [[bin]] block. Dubbla hakparenteser i TOML betyder att man skapar en lista, dvs multipla block.

# Cargo.toml
[package]
name = "hello"

[[bin]]
name = "print-baby-print"
path = "src/bin/print.rs"
# if you read my article about Apollo 11, you get the joke
. . .

Om man har både ett bibliotek plus ett (eller flera) program, så måste detta (dessa) importera biblioteket. Namnet fås av vad som anges i Cargo.toml. Givet att projektet ser ut som nedan:

my_project
├── Cargo.toml
└── src/
    ├── lib.rs
    └── main.rs
# Cargo.toml
[package]
name = "hello"
. . .

Så, ser Rust koden ut som följer:

// src/lib.rs
pub fn message() {
    let txt = "Tjabba Habba";
    println!("{} --> {}", txt, txt.to_uppercase());
}
// src/main.rs
use hello::message;
fn main() {
    println!("Hello from a Rust program");
    message();
}

Kör man sen programmet, ser det ut så här:

$ cargo run
   Compiling hello v0.1.0 (/home/jensr/RustroverProjects/my_project)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.11s
     Running `target/debug/hello`
Hello from a Rust program
Tjabba Habba --> TJABBA HABBA
$
$ ./target/debug/hello 
Hello from a Rust program
Tjabba Habba --> TJABBA HABBA

Testprogram

Det finns två testformer i Rust (det finns en tredje form, som jag vägrar kalla "test" - återkommer); enhetstest (unit tests) och integrationstest (integration tests).

Den första formen definieras vanligtvis med en inline module med namnet tests och annoterad med #[cfg(test)], samt varje testfunktion annoteras med #[test].

pub fn sum(n: i32) -> u64 {
    (n * (n + 1) / 2) as u64
}

#[cfg(test)]
mod tests {
    use super::*; //import parent
    // use crate::numbers::sum; //fully qualified import

    #[test]
    fn sum_of_zero_should_return_zero() {
        assert_eq!(sum(0), 0);
    }
    #[test]
    fn sum_of_one_should_return_one() {
        assert_eq!(sum(1), 1);
    }
    #[test]
    fn sum_of_ten_should_return_fiftyfive() {
        assert_eq!(sum(10), 55);
    }
}

Sen kör man alla test med cargo test.

$ cargo test
...
running 3 tests
test numbers::tests::sum_of_one_should_return_one ... ok
test numbers::tests::sum_of_ten_should_return_fiftyfive ... ok
test numbers::tests::sum_of_zero_should_return_zero ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
...

Den andra formen utgörs av separata (test-)program i katalogen tests/ och körs tillsammans med övriga test. Varje testfunktion ska annoteras på samma sätt som för enhetstesterna.

my_project
├── Cargo.toml
├── src/
│   └── lib.rs
└── tests/
    └── numbers_test.rs

Genererad programdokumentation

Ända sedan James Gosling införde genererad programdokumentation (JavaDoc), baserad på en idé av Donald Knuth (han med TeX och algoritm-böckerna) kallat literate programming, så har varje självrespekterande programmeringsspråk något liknande. Självfallet har Rust sin variant (rustdoc), inspirerad av javadoc-syntaxen och viss mån doxygen i C/C++-världen.

//! Simple _number-oriented_ functions
//!
//! # Examples
//! ```
//! use hello::numbers;
//! let k = 10;
//! println!("SUM(1..{}) = {}", k, numbers::sum(k));
//! assert_eq!(numbers::sum(10), 55);
//! ```

/// Computes the sum `1+2+...+n`
/// using the Gauss summation formula.
pub fn sum(n: u32) -> u64 {
    (n * (n + 1) / 2) as u64
}

Syntaxen i en doc-comment är Markdown, som du kan se i kod-fragmentet. Kursiv text _number-oriented_ respektive rubrik # Examples.

Du har säkert redan noterat att det finns två kommentarsformer; /// ... och //! .... Skillnaden är lite subtil och lätt att förbise; den första används före ett element, såsom funktionen sum() ovan. Den andra används inuti ett element, i detta fall inuti modulen numbers.

Alternativt, för den som föredrar den javadoc inspirerade syntaxen ser det ut så här:

/*! Simple _number-oriented_ functions
 *
 * # Examples
 *
 * ```
 * use hello::numbers::sum;
 * let k = 10;
 * println!("SUM(1..{}) = {}", k, sum(k));
 * assert_eq!(sum(10), 55);
 * ```
 */ 

/** 
 * Computes the sum `1+2+...+n` using the Gauss summation formula.
 */
pub fn sum(n: u32) -> u64 {
    (n * (n + 1) / 2) as u64
}

Du genererar dokumentationen med cargo doc (föga förvånande). Vad som inte är uppenbart till att börja med är att bara publika medlemmar av definierat bibliotekt tas med. Av det skälet är det vanligt att man under utvecklingen tar med både privata medlemmar och alla program, på följande vis:

$ cargo doc --document-private-items --bins --open
 Documenting hello v0.1.0 (/home/jensr/RustroverProjects/simple-project)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.68s
     Opening /home/jensr/RustroverProjects/simple-project/target/doc/hello_app/index.html

Kodfragment i dokumentationen

Om du kikar noga i dokumentationskommentaren ovan, ser du att det finns kodfragment enligt Markdown-syntaxen. Dylika fragment kompileras i samband med genereringen, så att man snabbt kan bli varse eventuella regressionsproblem (någon ändrar i produktionskoden men glömmer att uppdatera kommentaren).

Om fragmentet också innehåller testsatser såsom assert_eq!(sum(10), 55);, utförs dessa som en "tredje" form av test, kallat doctests, i samband med att man kör cargo test.


Externa tredjeparts-bibliotek

Som jag var inne på i början av denna artikel, så utgör möjligheten att inkorporera främmande bibliotek en essentiell komponent i ett byggnationsverktyg. Cargo har givetvis denna funktionalitet precis som Maven i Java-världen.

Första steget är att leta reda på vilket eller vilka bibliotek man behöver. I Java-världen söker man hos MVN Repository, i Node.js-världen söker man hos NPM.js och så vidare.

I Rust-världen söker man på crates.io (det officiella registret) eller alternativet lib.rs (ett tredjepartsgränssnitt med bättre kategorisering och kvalitetssignaler). Dokumentationen för varje publicerad crate byggs automatiskt och ligger på docs.rs — ingen behöver hantera en server för sina egna API-docs.

Att lägga till ett beroende görs med cargo add, som använder den senaste versionen och uppdaterar konfigurationsfilen:

$ cargo add rand
    Updating crates.io index
      Adding rand v0.10.2 to dependencies
             Features:
             + std
             + std_rng
             + thread_rng
             - serde
             - simd_support
             ...
# Cargo.toml
[dependencies]
rand = "0.10.2"

Ett versionskrav som "0.9" är inte "exakt 0.9" utan ett intervall. Cargo följer SemVer (major.minor.patch): en inkrementering av den major betyder "kan bryta bakåtkompatibilitet".

cargo build skapar också Cargo.lock, som listar den exakta version som valdes för varje crate i hela grafen — direkta beroenden såväl som deras beroenden. Nästa bygge, på vilken maskin som helst, använder exakt samma versioner tills man kör cargo update. Det här är precis samma typ av hantering som package-lock.json i Node.js-världen. Man kan lista alla beroenden av ett projekt med cargo tree.

$ cargo tree --depth 2
hello v0.1.0 (/home/jensr/RustroverProjects/simple-project)
├── rand v0.10.2
│   ├── chacha20 v0.10.2
│   ├── getrandom v0.4.3
│   └── rand_core v0.10.1
└── rand_distr v0.6.0
    ├── num-traits v0.2.19
    │   [build-dependencies]
    └── rand v0.10.2 (*)

Plottning av Normal-fördelning

Som avslutning på denna artikel visar jag ett litet program som använder två externa bibliotek och plottar en typisk Normal-fördelning som ett histogram av stjärnor. Först behöver vi lägga till våra två beroenden:

$ cargo add rand_distr rand

Vilket uppdaterar konfigurationsfilen på följande vis:

# Cargo.toml
# ...
[dependencies]
rand = "0.10.2"
rand_distr = "0.6.0"

Själva programmet ser ut så här:

use rand::rng;
use rand_distr::{Distribution, Normal};

const N: usize       = 10_000;       // number of samples
const MEAN: f64      = 0.0;          // parameters
const STDDEV: f64    = 2.0;
const BUCKETS: usize = 21;           // histogram resolution
const BOUND: f64     = 4.0 * STDDEV; // histogram range
const LOW: f64       = -BOUND;
const HIGH: f64      = BOUND;
const MAX_STARS: f64 = 40.0;         // max width of stars

fn main() {
    // 1. Generate the samples into a Vec.
    let mut rng = rng();
    let samples: Vec<f64> = Normal::new(MEAN, STDDEV).unwrap()
        .sample_iter(&mut rng)
        .take(N)
        .collect();

    // 2. Aggregate into a histogram table (bucket index -> count).
    let width = (HIGH - LOW) / BUCKETS as f64;
    let mut counts = vec![0usize; BUCKETS]; //initialize with BUCKETS number of zeroes
    for &x in &samples {
        let raw = ((x - LOW) / width).floor() as isize; //isize similar to long int
        let idx = raw.clamp(0, BUCKETS as isize - 1) as usize; //usize similar to unsigned long int
        counts[idx] += 1;
    }

    // 3. Print it: one row per bucket, N stars proportional to the count.
    let peak = counts.iter().copied().max().unwrap_or(1).max(1) as f64;
    println!("n = {N}, mean = {MEAN}, stddev = {STDDEV}");
    for (idx, &cnt) in counts.iter().enumerate() {
        let lb = LOW + width * idx as f64;
        let ub = lb + width;
        let freq = cnt as f64;
        let stars = (freq / peak * MAX_STARS).round() as usize;
        println!("[{lb:+5.2}, {ub:+5.2})  {cnt:5}  {}", "*".repeat(stars));
    }
}

Kör vi sen programmet kan det se ut så här:

$ cargo run --bin plot
   Compiling hello v0.1.0 (/home/jensr/RustroverProjects/simple-project)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s
     Running `target/debug/plot`
n = 10000, mean = 0, stddev = 2
[-8.00, -7.24)      2  
[-7.24, -6.48)      2  
[-6.48, -5.71)     17  
[-5.71, -4.95)     73  **
[-4.95, -4.19)    104  ***
[-4.19, -3.43)    241  ******
[-3.43, -2.67)    466  ************
[-2.67, -1.90)    790  *********************
[-1.90, -1.14)   1119  ******************************
[-1.14, -0.38)   1430  **************************************
[-0.38, +0.38)   1496  ****************************************
[+0.38, +1.14)   1457  ***************************************
[+1.14, +1.90)   1176  *******************************
[+1.90, +2.67)    779  *********************
[+2.67, +3.43)    438  ************
[+3.43, +4.19)    237  ******
[+4.19, +4.95)    104  ***
[+4.95, +5.71)     45  *
[+5.71, +6.48)     12  
[+6.48, +7.24)      6  
[+7.24, +8.00)      6  
$ 

Sammanfattning

Vi har rest en bra bit i den här artikeln: från rustc hello.rs och dess *.o-filer, via make, Ant och Maven fram till cargo. Längs vägen har vi tittat på hur cargo strukturerar ett projekt (src/main.rs, src/lib.rs, src/bin/), hur moduler byggs upp (fil-baserade, inline, med #[cfg(...)]), hur man testar (enhetstest, integrationstest och doctests), hur man genererar dokumentation (rustdoc) och slutligen hur man inkorporerar tredjeparts-bibliotek från crates.io — med ett litet demo-program som facit.

Den röda tråden genom hela artikeln är egentligen bara en enda mening, gömd i titeln: konvention framför konfiguration. Samma idé som Jason van Zyl gav Java-världen med Maven, har Rust fått med sig från start, inbyggd i själva verktyget. Filnamnet main.rs är konfigurationen. Katalogen src/bin/ är konfigurationen. Det finns i praktiken inget att ställa in för att komma igång — man skapar, kompilerar, testar, dokumenterar och publicerar med samma verktyg, samma kommandon och samma vokabulär, oavsett vilket Rust-projekt man öppnar.

Jämför man med C++, där varje projekt kan ha sin egen kombination av CMake/make, Conan/vcpkg/FetchContent, Doxygen och en handfull hemsnickrade skript, blir skillnaden tydlig. Det är inte det att C++ saknar verktyg — det är att det saknar ett verktyg alla är överens om. Cargo må vara "bara" ett byggverktyg, men det är förmodligen den enskilda komponent som gjort mest för Rusts rykte om att vara ett utvecklarvänligt språk.