Articolo originale: https://www.freecodecamp.org/news/rust-in-replit/

Per sei anni di fila, Rust è stato votato su Stack Overflow come il linguaggio di programmazione più amato.

Quindi se sei pronto a imparare questo popolare linguaggio di programmazione, questo corso ti farà conoscere Rust, così da poter iniziare a usarlo per i tuoi progetti.

Lavorerai interamente all'interno del tuo browser usando l'ambiente di programmazione interattivo Replit. freeCodeCamp ha stretto una collaborazione con Replit, che ha reso possibile questo corso.

La versione video di questo corso è disponibile (in lingua originale inglese) sul canale YouTube di freeCodeCamp.

Per ottenere il massimo da questo corso, dovresti avere una conoscenza intermedia di almeno un altro linguaggio di programmazione. Se sei nuovo al mondo della programmazione, dovresti provare il curriculum interattivo di freeCodeCamp e poi tornare a questo corso.

Per aiutarti a imparare Rust, creeremo due progetti:

  1. Una calcolatrice per la riga di comando
  2. Uno strumento per la riga di comando che prende due immagini e ne combina i pixel

Sommario

Ecco le sezioni e gli argomenti che tratteremo in questo corso. Puoi cliccare sulle voci nel sommario per andare direttamente a una particolare sezione, o puoi affrontare il corso dall'inizio alla fine.

Una panoramica su Rust

Rust è un linguaggio di programmazione al livello di sistema.

"[Rust] tratta dettagli di basso livello di gestione della memoria, rappresentazione dei dati e concorrenza."

"... il linguaggio è progettato per guidarti naturalmente verso del codice affidabile, efficiente in termini di velocità e utilizzo della memoria." (Fonte: documentazione Rust)

La strumentazione principale dell'ecosistema Rust è:

  • rustc – Il compilatore che prende il codice Rust e lo compila in binario (codice leggibile dalla macchina)
  • rustup – L'utilità da riga di comando per installare e aggiornare Rust
  • cargo – Il sistema di build e gestore di pacchetti di Rust

Come usare Rust in Replit

Per questo corso, utilizzerai questo repository GitHub come boilerplate.

Per iniziare, clicca sul seguente link per creare un nuovo REPL dal boilerplate:

run on replit

Poi nella finestra "Import from GitHub", apri il menu a discesa "Language" e seleziona "Bash":

The Import from GitHub modal on Replit showing the Language dropdown and selecting Bash as the language.

Clicca sul pulsante "Import from GitHub" in basso a destra per importare il codice di partenza in Replit.

Infine, per iniziare il corso, clicca sul pulsante Run nella parte superiore dello schermo e segui le istruzioni nella console sulla destra:

The beginning of the course after clicking the Run button, with a README with instructions on the left and the Replit console on the right with a welcome message.

Le basi di Rust

Variabili in Rust

Puoi dichiarare delle variabili usando le parole chiave let, const o static:

let my_variable = 0;
const MY_CONSTANT: u8 = 0;
static MY_STATIC: u8 = 0;

Come impostazione predefinita, tutte le variabili sono immutabili. Puoi rendere mutabile una variabile usando la parola chiave mut:

let mut my_mutable_variable = 0;

In Rust, le convenzioni di nomenclatura sono le seguenti:

Oggetto Stile
Variabili snake_case
Funzioni snake_case
File snake_case
Costanti SCREAMING_SNAKE_CASE
Static SCREAMING_SNAKE_CASE
Tipi PascalCase
Tratti PascalCase
Enum PascalCase

Dato che Rust è staticamente tipizzato, devi scrivere esplicitamente il tipo di variabile – a meno che la variabile venga dichiarata con let e il tipo può essere dedotto.

Funzioni in Rust

Puoi dichiarare delle funzioni usando la parola chiave fn:

fn main() {
  // Questo è un commento
}

Le funzioni restituiscono qualcosa con la parole chiave return e devi specificare esplicitamente il tipo del valore di ritorno di una funzione, a meno che si tratti di una tupla vuota ():

fn main() -> () { // Tipo del valore di ritorno non necessario
  my_func();
}

fn my_func() -> u8 {
  return 0;
}

Le funzioni restituiscono un'espressione anche omettendo il punto e virgola:

fn my_func() -> u8 {
  0
}

I parametri di funzione vengono scritti usando la sintassi ::

fn main() {
  let _unused_variable = my_func(10);
}

fn my_func(x: u8) -> i32 {
  x as i32
}

Il trattino basso prima del nome di una variabile è una convenzione per indicare che la variabile non è utilizzata. La parola chiave as afferma il tipo dell'espressione, a condizione che la conversione sia valida.

Stringhe e slice in Rust

Un motivo di confusione comune per i principianti di Rust è la differenza tra la struct String e il tipo str.

let my_str: &str = "Hello, world!";

let my_string: String = String::from("Hello, world!");

Nell'esempio qui sopra, my_str è un riferimento a una stringa letterale, e my_string è un'istanza della struct String.

Un'importante distinzione tra le due è che my_str è memorizzata nello stack, mentre my_string è allocata nella memoria heap. Ciò significa che il valore di my_str non può cambiare e la sua dimensione è fissa, mentre my_string può avere una dimensione sconosciuta al tempo di compilazione.

Una stringa letterale è anche conosciuta come string slice (letteralmente "fetta"), poiché &str fa riferimento a parte di una stringa. In linea generale, ecco la somiglianza tra stringhe e array:

let my_string = String::from("The quick brown fox");
let my_str: &str = &my_string[4..9]; // "quick"

let my_arr: [usize; 5] = [1, 2, 3, 4, 5];
let my_arr_slice: &[usize] = &my_arr[0..3]; // [1, 2, 3]

La notazione [T; n] viene usata per creare un array di n elementi di tipo T.

Il tipo char in Rust

Un char è un USV (Unicode Scalar Value), che è rappresentato in Unicode con valori come U+221E – il codice Unicode per '∞'. Puoi pensare a un insieme o un array di char come a una stringa:

let my_str: &str = "Hello, world!";

let collection_of_chars: &str = my_str.chars().as_str();

Tipi numerici in Rust

Esistono molti tipi di numeri in Rust:

  • Interi senza segno: u8, u16, u32, u64, u128
  • Interi con segno: i8, i16, i32, i64, i128
  • Numeri in virgola mobile: f32, f64

Gli interi senza segno rappresentano solo gli interi positivi.

Gli interi con segno rappresentano gli interi sia positivi che negativi.

Mentre i numeri in virgola mobile rappresentano numeri frazionari positivi e negativi.

Struct in Rust

Una struct è un tipo di dato personalizzato usato per raggruppare dati correlati. Ti sei già imbattuto in una struct nella sezione Stringhe e slice:

struct String {
  vec: Vec<u8>,
}

La struct String consiste di un campo vec, che è un Vec di u8. Vec è un array con dimensione dinamica.

Un'istanza di una struct viene dichiarata dando valori ai campi:

struct MyStruct {
  field_1: u8,
}

let my_struct = MyStruct { field_1: 0, };

Precedentemente, la struct String è stata usata con la sua funzione from per creare una String da una &str. Ciò è possibile in quanto la funzione from è implementata per String:

impl String {
  fn from(s: &str) -> Self {
    String {
      vec: Vec::from(s.as_bytes()),
    }
  }
}

Si utilizza la parola chiave Self al posto del tipo di struct.

Le struct possono anche prendere altre varianti:

struct MyUnitStruct;
struct MyTupleStruct(u8, u8);

Enum in Rust

Analogamente ad altri linguaggi, gli enum sono utili per agire come tipi e come valori.

enum MyErrors {
  BrainTooTired,
  TimeOfDay(String)
  CoffeeCupEmpty,
}

fn work() -> Result<(), MyErrors> { // Result is also an enum
  if state == "missing semi-colon" {
    Err(MyErrors::BrainTooTired)
  } else if state == "06:00" {
    Err(MyErrors::TImeOfDay("It's too early to work".to_string()))
  } else if state == "22:00" {
    Err(MyErrors::TimeOfDay("It's too late to work".to_string()))
  } else if state == "empty" {
    Err(MyErrors::CoffeeCupEmpty)
  } else {
    Ok(())
  }
}

Macro in Rust

Una macro è simile a una funzione, ma puoi pensarla come un pezzo di codice che scrive altro codice. Per ora, le differenze principali da tenere a mente tra una funzione e una macro sono:

  • Le macro vengono chiamate usando un punto esclamativo (!, anche detto bang)
  • Le macro possono prendere un numero variabile di argomenti, mentre le funzioni non possono

Una delle macro più comuni è println!, che stampa sulla console:

let my_str = "Hello, world!";
println!("{}", my_str);

Si utilizza la sintassi {} per inserire una variabile in una stringa.

Un'altra macro comune è panic!. Panic è il modo di Rust di dare errore. È saggio pensare a un panic in Rust come a un errore mal gestito. La macro accetta una stringa letterale e "va nel panico" dando il messaggio.

let am_i_an_error = true;

if (am_i_an_error) {
  panic!("There was an error");
}
$ cargo run
   Compiling fcc-rust-in-replit v0.1.0 (/home/runner/Rust-in-Replit)
    Finished dev [unoptimized + debuginfo] target(s) in 1.66s
     Running `target/debug/calculator`
thread 'main' panicked at 'There was an error', src/main.rs
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Proprietà in Rust

Un'importante concetto in Rust è la proprietà. Esistono tre regole principali:

  • Ogni valore in Rust ha una variabile che è chiamata proprietario.
  • Può esserci solo un proprietario alla volta.
  • Quando il proprietario è fuori dalla visibilità, il valore viene eliminato.
    (Fonte: The Rust Book)

Questo è il modo in cui Rust la fa franca senza avere un tipico garbage collector, mentre non richiede al programmatore di gestire in modo esplicito la memoria. Ecco un esempio di proprietà:

fn main() { // first_string non è stata ancora dichiarata -> non ha valore
  let first_string = String::from("freeCodeCamp"); // first_string adesso è il proprietario del valore "freeCodeCamp"
  let second_string = first_string; // second_string prende la proprietà del valore "freeCodeCamp"

  println!("Hello, {}!", first_string); // first_string NON è valida, perché il valore è stato spostato a second_string
}

Dato che la macro println! prova a fare riferimento a un valore non valido, il codice non viene compilato. Per porvi rimedio, invece di spostare il valore di first_string in second_string, second_string può essere assegnata come riferimento a first_string:

fn main() {
  let first_string: String = String::from("freeCodeCamp");
  let second_string: &String = &first_string; // first_string è ancora il proprietario del valore "freeCodeCamp"

  println!("Hello, {}!", first_string);
}

La e commerciale (&) indica che il valore è un riferimento, ovvero che second_string non possiede la proprietà di "freeCodeCamp", ma invece punta alla stessa posizione in memoria di first_string.

Progetto #1 – Costruire una calcolatrice per la riga di comando in Rust

Risultato del progetto

Alla fine di questo progetto, sarai in grado di eseguire delle operazioni aritmetiche di base su dei numeri usando la riga di comando.

Ecco degli esempi di input e output attesi:

$ calculator 1 + 1
$ 1 + 1 = 2

$ calculator 138 / 4
$ 138 / 4 = 34.5

Metodologia del progetto calcolatrice

Step 1 – Creare un nuovo progetto

Usa Cargo per chiamare un nuovo progetto chiamato calculator:

$ cargo new calculator

Questo crea una nuova cartella chiamata calculator, inizializzandola come un repository Git, e aggiunge il boilerplate per il tuo progetto.

Il boilerplate include:

  • Cargo.toml – Il file manifest usato da Cargo per gestire i metadati del tuo progetto
  • src/ – La cartella in cui si trova il tuo progetto
  • src/main.rs – Il file che Cargo usa di default come punto d'ingresso dell'applicazione

Step 2 – Capire la sintassi

Il file calculator/Cargo.toml contiene:

[package]
name = "calculator"
version = "0.1.0"
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

[package] denota i metadati del tuo progetto.

L'intestazione [dependencies] denota i crate da cui dipende il tuo progetto. I crate sono come delle librerie esterne.

Il file calculator/src/main.rs contiene:

fn main() {
  println!("Hello, world!");
}

Questo file contiene una dichiarazione di funzione con l'handle main. Di default, rustc chiama prima la funzione main ogni volta che l'eseguibile viene eseguito.

println! è una macro integrata che stampa sulla console.

Step 3 – Eseguire il progetto

Puoi usare Cargo per eseguire il codice del tuo progetto:

# All'interno della cartella calculator/
$ cargo run
   Compiling fcc-rust-in-replit v0.1.0 (/home/runner/Rust-in-Replit-1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.80s
     Running `target/debug/calculator`
Hello, world!

Oppure puoi usare rustc per compilare il tuo progetto e poi puoi eseguire il binario:

# All'interno della cartella calculator/
$ rustc src/main.rs
$ ./main
Hello, world!

Step 4 – Argomenti della riga di comando

La libreria standard Rust contiene un modulo env, che permette di accedere agli argomenti della riga di comando passati chiamando il programma.

Gli export indispensabili dal modulo env sono la funzione args e la struct Args. La funzione args restituisce un'istanza della struct Args ed è importata nell'ambito di visibilità del file con:

use std::env::{args, Args};

Per farti un'idea dell'aspetto della struct Args, la variabile args è stampata sulla console:

fn main() {
  let args: Args = args();
  println!("{:?}", args);
}
$ cargo run -- fCC
   Compiling calculator v0.1.0 (/home/runner/Rust-in-Replit/calculator)
    Finished dev [unoptimized + debuginfo] target(s) in 1.71s
     Running `target/debug/calculator`
Args { inner: ["target/debug/toto", "fCC"] }

Il codice qui sopra mostra che la struct Args contiene un field (campo) chiamato inner che consiste nella posizione del binario compilato e gli argomenti da riga di comando passati al programma.

Per accedere ai valori degli argomenti, puoi usare il metodo nth sulla variabile args. Il metodo nth prende un argomento index e restituisce il valore a quell'indice racchiuso in un Option. Quindi il valore deve essere spacchettato (unwrapped).

fn main() {
  let mut args: Args = args();

  let first: String = args.nth(1).unwrap();
}

La variabile args deve essere dichiarata come mutabile, perché il metodo nth itera mutabilmente sugli elementi e rimuove gli elementi consultati.

fn main() {
  let mut args: Args = args();

  // Il primo argomento è la posizione del binario compilato, quindi saltalo
  let first: String = args.nth(1).unwrap();
  // Dopo aver consultato il secondo argomento, l'elemento successivo dell'iteratore diventa il primo
  let operator: String = args.nth(0).unwrap();
  let second: String = args.nth(0).unwrap();

  println!("{} {} {}", first, operator, second);
}
$ cargo run -- 1 + 1
   Compiling calculator v0.1.0 (/home/runner/Rust-in-Replit/calculator)
    Finished dev [unoptimized + debuginfo] target(s) in 1.71s
     Running `target/debug/calculator`
1 + 1

Step 5 – Interpretare le stringhe in numeri

Le variabili first e second sono stringhe e devi effettuarne il parsing in numeri. La struct String implementa il metodo parse, che prende un'annotazione di tipo e restituisce un Result contenente il valore interpretato.

use std::env::{args, Args};

fn main() {
  let mut args: Args = args();

  let first: String = args.nth(1).unwrap();
  let operator: String = args.nth(0).unwrap();
  let second: String = args.nth(0).unwrap();

  let first_number = first.parse::<f32>().unwrap();
  let second_number = second.parse::<f32>().unwrap();

  println!("{} {} {}", first_number, operator, second_number);
}

Il metodo parse qui sopra fa uso della sintassi turbofish per specificare il tipo in cui provare a interpretare la stringa.

Step 6 – Eseguire operazioni aritmetiche di base

Rust utilizza gli operatori per addizione, sottrazione, moltiplicazione e divisione.

Per gestire le operazioni, definisci una funzione chiamata operate che prende tre argomenti: l'operatore come char e due numeri come f32. La funzione dovrebbe restituire un f32 che rappresenta il risultato dell'operazione.

fn operate(operator: char, first_number: f32, second_number: f32) -> f32 {
  match operator {
    '+' => first_number + second_number,
    '-' => first_number - second_number,
    '/' => first_number / second_number,
    '*' | 'X' | 'x' => first_number * second_number,
    _ => panic!("Invalid operator used."),
  }
}

L'espressione match funziona in modo simile all'istruzione switch in altri linguaggi. L'espressione match prende un valore e una lista di braccia. Ogni braccio ha un pattern e un blocco. Il pattern è un valore con cui effettuare il confronto e il blocco è il codice da eseguire se il pattern corrisponde. Il pattern _ è un jolly e agisce come una clausola else.

Il braccio della moltiplicazione include il confronto OR per permettere la gestione dei casi per X e x.

Ora, per chiamare operate con operator, devi convertirla prima in un char. Puoi farlo usando il metodo chars sulla struct String che restituisce un iteratore sui caratteri della stringa. Poi, il primo carattere viene spacchettato:

fn main() {
  let mut args: Args = args();

  let first: String = args.nth(1).unwrap();
  let operator: char = args.nth(0).unwrap().chars().next().unwrap();
  let second: String = args.nth(0).unwrap();

  let first_number = first.parse::<f32>().unwrap();
  let second_number = second.parse::<f32>().unwrap();
  let result = operate(operator, first_number, second_number);

  println!("{} {} {}", first_number, operator, second_number);
}

Il valore restituito da operate è memorizzato nella variabile result.

Step 7 – Formattare l'output

Per ottenere l'output desiderato, le variabili first_number, second_number, operator e result devono essere formattate. Puoi usare la macro format! per creare una String da una stringa formattata e una lista di argomenti:

To get the desired output, the first_number, second_number, operator, and result variables need to be formatted. You can use the format! macro to create a String from a format string and a list of arguments:

fn output(first_number: f32, operator: char, second_number: f32, result: f32) -> String {
  format!(
    "{} {} {} = {}",
    first_number, operator, second_number, result
  )
}

Step 8 – Mettere tutto insieme

use std::env::{args, Args};

fn main() {
  let mut args: Args = args();

  let first: String = args.nth(1).unwrap();
  let operator: char = args.nth(0).unwrap().chars().next().unwrap();
  let second: String = args.nth(0).unwrap();

  let first_number = first.parse::<f32>().unwrap();
  let second_number = second.parse::<f32>().unwrap();
  let result = operate(operator, first_number, second_number);

  println!("{}", output(first_number, operator, second_number, result));
}

fn output(first_number: f32, operator: char, second_number: f32, result: f32) -> String {
  format!(
    "{} {} {} = {}",
    first_number, operator, second_number, result
  )
}

fn operate(operator: char, first_number: f32, second_number: f32) -> f32 {
  match operator {
    '+' => first_number + second_number,
    '-' => first_number - second_number,
    '/' => first_number / second_number,
    '*' | 'X' | 'x' => first_number * second_number,
    _ => panic!("Invalid operator used."),
  }
}

Far fare il build del codice in un binario eseguibile, esegui il seguente comando:

$ cargo build --release
   Compiling calculator v0.1.0 (/home/runner/Rust-in-Replit/calculator)
    Finished release [optimized] target(s) in 3.26s

Il flag --release comunica a Cargo di fare il build del binario in modalità di rilascio. Ciò ridurrà la dimensione del binario e rimuoverà anche qualsiasi informazione di debugging.

Il build del binario avviene nella cartella target/release. Per eseguire il binario e testare la tua applicazione esegui il seguente comando:

$ target/release/calculator 1 + 1
1 + 1 = 2

Progetto #2 – Costruire un combinatore di immagini in Rust

Risultato del progetto

Alla fine di questo progetto, sarai in grado di combinare due immagini usando la riga di comando.

Ecco un esempio di un input atteso:

$ combiner ./image1.png ./image2.png ./output.png

Per un esempio dell'output, guarda la prima immagine in questo articolo ☝️

Metodologia del progetto combinatore di immagini

Step 1 - Creare un nuovo progetto

Usa Cargo per creare un nuovo progetto chiamato combiner:

$ cargo new combiner

Step 2 - Aggiungere un nuovo modulo per args

Per evitare che il file main.rs diventi troppo difficile da gestire, crea un nuovo file chiamato args.rs nella cartella src.

All'interno di args.rs, crea una funzione chiamata get_nth_arg che prende un n usize e restituisce una String. Quindi, dal modulo std::env, chiama la funzione args e concatena il metodo nth per ottenere l'n-esimo argomento e spacchettare il valore:

fn get_nth_arg(n: usize) -> String {
  std::env::args().nth(n).unwrap()
}

Definisci una struct pubblica chiamata Args che consiste di tre campi pubblici di tipo String: image_1, image_2 e output:

pub struct Args {
  pub image_1: String,
  pub image_2: String,
  pub output: String,
}

Dichiara la struct e i suoi campi come pubblici con la parola chiave pub, in modo da potervi accedere dall'esterno del file args.rs.

Infine, puoi usare la funzione get_nth_arg per creare una nuova struct Args in una funzione new:

impl Args {
  pub fn new() -> Self {
    Args {
      image_1: get_nth_arg(1),
      image_2: get_nth_arg(2),
      output: get_nth_arg(3),
    }
  }
}

Nel complesso, il file args.rs ha questo aspetto:

pub struct Args {
  pub image_1: String,
  pub image_2: String,
  pub output: String,
}

impl Args {
  pub fn new() -> Self {
    Args {
      image_1: get_nth_arg(1),
      image_2: get_nth_arg(2),
      output: get_nth_arg(3),
    }
  }
}

fn get_nth_arg(n: usize) -> String {
  std::env::args().nth(n).unwrap()
}

Step 3 – Importare e usare il modulo args

All'interno di main.rs, devi dichiarare il file args.rs come modulo. Poi, per usare la struct Args occorre importarla:

mod args;
use args::Args;

fn main() {
  let args = Args::new();
  println!("{:?}", args);
}

Ma testando il codice scoprirai un errore:

$ cargo run -- arg1 arg2 arg3
   Compiling combiner v0.1.0 (/home/runner/Rust-in-Replit/combiner)
error[E0277]: `args::Args` doesn't implement `Debug`
  --> src/main.rs:12:20
   |
12 |   println!("{:?}", args);
   |                    ^^^^ `args::Args` cannot be formatted using `{:?}`
   |
   = help: the trait `Debug` is not implemented for `args::Args`
   = note: add `#[derive(Debug)]` or manually implement `Debug`
   = note: required by `std::fmt::Debug::fmt`
   = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)

error: aborting due to previous error

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

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

Analogamente al modo in cui le funzioni sono implementate per le struct, i tratti possono essere implementati per le struct. Tuttavia, il tratto Debug ha la particolarità di poter essere implementato usando attributi:

#[derive(Debug)]
pub struct Args {
  pub image_1: String,
  pub image_2: String,
  pub output: String,
}

Il tratto Debug è stato derivato dalla struct Args. Ciò vuol dire che il tratto Debug è implementato automaticamente per la struct, senza che tu debba farlo manualmente 🚀.

Se adesso esegui il codice, funzionerà:

$ cargo run -- arg1 arg2 arg3
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/combiner arg1 arg2 arg3`
Args { image_1: "arg1", image_2: "arg2", output: "arg3" }

Step 4 – Aggiungere un crate esterno

Nello stesso modo in cui altri linguaggi hanno librerie o pacchetti, Rust ha i crate. Per poter codificare e decodificare immagini, puoi usare il crate image.

Aggiungi il crate image versione 0.23.14 al file Cargo.toml:

[package]
name = "combiner"
version = "0.1.0"
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
image = "0.23.14"

Alla prossima chiamata cargo run, Cargo recupererà e installerà il crate image.

Step 5 – Leggere un file immagine

Il crate image contiene un modulo io che comprende una struct Reader. Questa struct implementa una funzione open che prende un percorso a un file immagine e restituisce un Result contenete un lettore. Puoi formattare e decodificare questo lettore per ottenere il formato (ad esempio PNG, JGP e via dicendo) e i dati dell'immagine.

Crea una funzione chiamata find_image_from_path per aprire il file immagine dall'argomento path:

fn find_image_from_path(path: String) -> (DynamicImage, ImageFormat) {
  let image_reader: Reader<BufReader<File>> = Reader::open(path).unwrap();
  let image_format: ImageFormat = image_reader.format().unwrap();
  let image: DynamicImage = image_reader.decode().unwrap();
  (image, image_format)
}

Le variabili image e image_format vengono restituite come una tupla.

In cima al file, includi gli import necessari:

use image::{ io::Reader, DynamicImage, ImageFormat };

fn main() {
  // ...
  let (image_1, image_1_format) = find_image_from_path(args.image_1);
  let (image_2, image_2_format) = find_image_from_path(args.image_2);
}

All'interno di main, la tupla restituita può essere destrutturata in due nuove variabili per ogni percorso di immagine.

Step 6 – Gestire gli errori con Result

È importante essere in grado di gestire gli errori che possono presentarsi. Ad esempio, potresti avere un caso in cui due immagini di formato diverso vengono passate come argomenti da combinare.

Un modo semantico di gestire un errore del genere è restituire un Result che può consistere in un Ok o un Err.

fn main() -> Result<(), ImageDataErrors> {
  let args = Args::new();
  println!("{:?}", args);

  let (image_1, image_1_format) = find_image_from_path(args.image_1);
  let (image_2, image_2_format) = find_image_from_path(args.image_2);

  if image_1_format != image_2_format {
    return Err(ImageDataErrors::DifferentImageFormats);
  }
  Ok(())
}

La funzione main restituisce un Err contenente un enum con un gruppo di varianti DifferentImageFormats se le due immagini non sono uguali. Altrimenti restituisce Ok con una tupla vuota.

L'enum è definito come:

enum ImageDataErrors {
  DifferentImageFormats,
}

Step 7 – Ridimensionare le immagini

Per rendere più semplice la combinazione delle immagini, devi ridimensionare l'immagine più grande fino a farla combaciare con le dimensioni di quella più piccola.

Prima di tutto, puoi trovare l'immagine più piccola usando il metodo dimensions che restituisce la larghezza e l'altezza dell'immagine come una tupla. Queste tuple possono essere confrontate e la più piccola restituita:

fn get_smallest_dimensions(dim_1: (u32, u32), dim_2: (u32, u32)) -> (u32, u32) {
  let pix_1 = dim_1.0 * dim_1.1;
  let pix_2 = dim_2.0 * dim_2.1;
  return if pix_1 < pix_2 { dim_1 } else { dim_2 };
}

I valori della tupla vengono consultati usando la notazione a punto con indicizzazione a base zero.

Se image_2 è l'immagine più piccola, allora image_1 deve essere ridimensionata fino a combaciare con le dimensioni più piccole. Altrimenti, image_2 deve essere ridimensionata.

fn standardise_size(image_1: DynamicImage, image_2: DynamicImage) -> (DynamicImage, DynamicImage) {
  let (width, height) = get_smallest_dimensions(image_1.dimensions(), image_2.dimensions());
  println!("width: {}, height: {}\n", width, height);

  if image_2.dimensions() == (width, height) {
    (image_1.resize_exact(width, height, Triangle), image_2)
  } else {
    (image_1, image_2.resize_exact(width, height, Triangle))
  }
}

Il metodo resize_exact implementato sulla struct DynamicImage importa mutabilmente l'immagine e usando gli argomenti width, height e FilterType ridimensiona l'immagine.

Usando il valore di ritorno della funzione standardise_size, puoi ridichiarare le variabili image_1 e image_2:

use image::{ io::Reader, DynamicImage, ImageFormat, imageops::FilterType::Triangle };

fn main() -> Result<(), ImageDataErrors> {
  // ...
  let (image_1, image_2) = standardise_size(image_1, image_2);
  Ok(())
}

Step 8 – Creare una FloatingImage

Per gestire l'output, crea una struct temporanea in cui conservare i metadati per l'immagine di output.

Definisci una struct chiamata FloatingImage per memorizzare width, height e data dell'immagine, così come il nome del file di output:

struct FloatingImage {
  width: u32,
  height: u32,
  data: Vec<u8>,
  name: String,
}

Poi, implementa una funzione new per FloatingImage che prende i valori width, height e name dell'immagine di output:

impl FloatingImage {
  fn new(width: u32, height: u32, name: String) -> Self {
    let buffer_capacity = 3_655_744;
    let buffer: Vec<u8> = Vec::with_capacity(buffer_capacity);
    FloatingImage {
      width,
      height,
      data: buffer,
      name,
    }
  }
}

Dato che non hai ancora creato i dati per l'immagine, crea un buffer in forma di un Vec di u8 con capacità di 3,655,744 (956 x 956 x 4). La sintassi <numeri>_<numeri> è la numerazione di facile lettura di Rust, che separa i numeri in gruppi di tre cifre.

Usa i valori width e height della variabile image_1 per creare un'istanza di FloatingImage e usa il terzo argomento memorizzato in args per impostare il nome di FloatingImage:

fn main() -> Result<(), ImageDataErrors> {
  // ...
  let mut output = FloatingImage::new(image_1.width(), image_1.height(), args.output);
  Ok(())
}

Dichiara  le variabili output come mutabili in modo da poter modificare il campo data in un secondo momento.

Step 9 – Creare i dati dell'immagine combinata

Per poter elaborare l'immagine, devi convertirla in un vettore di pixel RGBA. I pixel sono memorizzati come u8, perché il loro valore è compreso tra 0 e 255.

La struct DynamicImage implementa il metodo to_rgba8, che restituisce un ImageBuffer contenente un Vec<u8>, e ImageBuffer implementa il metodo into_vec, che restituisce Vec<u8>:

fn combine_images(image_1: DynamicImage, image_2: DynamicImage) -> Vec<u8> {
  let vec_1 = image_1.to_rgba8().into_vec();
  let vec_2 = image_2.to_rgba8().into_vec();

  alternate_pixels(vec_1, vec_2)
}

Poi, le variabili vec_1 e vec_2 vengono passate alla funzione alternate_pixels che restituisce i dati dell'immagine combinata alternando i set di pixel RGBA delle due immagini:

fn alternate_pixels(vec_1: Vec<u8>, vec_2: Vec<u8>) -> Vec<u8> {
  // Un Vec<u8> viene creato con la stessa lunghezza di vec_1
  let mut combined_data = vec![0u8; vec_1.len()];

  let mut i = 0;
  while i < vec_1.len() {
    if i % 8 == 0 {
      combined_data.splice(i..=i + 3, set_rgba(&vec_1, i, i + 3));
    } else {
      combined_data.splice(i..=i + 3, set_rgba(&vec_2, i, i + 3));
    }
    i += 4;
  }

  combined_data
}

La funzione set_rgba prende un riferimento a Vec<u8> e restituisce i set di pixel RGBA per quel Vec<u8> iniziando e terminando a un dato indice:

fn set_rgba(vec: &Vec<u8>, start: usize, end: usize) -> Vec<u8> {
  let mut rgba = Vec::new();
  for i in start..=end {
    let val = match vec.get(i) {
      Some(d) => *d,
      None => panic!("Index out of bounds"),
    };
    rgba.push(val);
  }
  rgba
}

La sintassi ..= è la sintassi range di Rust, che fa sì che l'intervallo includa il valore finale. Il simbolo * prima della variabile è l'operatore di de-referenziazione, che consente di accedere al valore della variabile.

Quindi, assegna il valore di ritorno di combine_images alla variabile combined_data:

fn main() -> Result<(), ImageDataErrors> {
  // ...
  let combined_data = combine_images(image_1, image_2);
  Ok(())
}

Step 10 – Inserire i dati combinati in FloatingImage

Per impostare i dati di combined_data nell'imagine output, viene definito un metodo su FloatingImage per impostare il campo data di output con il valore di combined_data.

Finora, hai implementato delle funzioni solo su struct. I metodi sono definiti in un modo simile, ma prendono un'istanza della struct come primo argomento:

struct MyStruct {
  name: String,
}
impl MyStruct {
  fn change_name(&mut self, new_name: &str) {
    self.name = new_name.to_string();
  }
}

let mut my_struct = MyStruct { name: String::from("Shaun") };
// my_struct.name == "Shaun"
my_struct.change_name("Tom");
// my_struct.name == "Tom"

Dato che hai bisogno di cambiare il valore dell'istanza di FloatingImage, il metodo set_data prende un riferimento mutabile all'istanza come primo argomento.

impl FloatingImage {
  // ...
  fn set_data(&mut self, data: Vec<u8>) -> Result<(), ImageDataErrors> {
    // If the previously assigned buffer is too small to hold the new data
    if data.len() > self.data.capacity() {
      return Err(ImageDataErrors::BufferTooSmall);
    }
    self.data = data;
    Ok(())
  }
}

L'enum deve essere esteso per includere il nuovo gruppo di varianti BufferTooSmall:

enum ImageDataErrors {
  // ...
  BufferTooSmall,
}

Nota: il metodo è ancora chiamato con un solo argomento:

fn main() -> Result<(), ImageDataErrors> {
  // ...
  output.set_data(combined_data)?;
  Ok(())
}

La sintassi ? alla fine dell'espressione è una scorciatoia per gestire il risultato della chiamata di una funzione. Se la funzione restituisce un errore, l'operatore di propagazione dell'errore restituirà l'errore dalla chiamata della funzione.

Step 11 – Scrivere l'immagine in un file

Finalmente, salva la nuova immagine in un file. Il crate image possiede una funzione save_buffer_with_format che la seguente forma:

fn save_buffer_with_format(
    path: AsRef<Path>,
    buf: &[u8],
    width: u32,
    height: u32,
    color: image::ColorType,
    format: image::ImageFormat
  ) -> image::ImageResult<()>;

Vedendo il modo in cui AsRef è implementato per String, puoi usare un argomento di tipo String per path.

fn main() -> Result<(), ImageDataErrors> {
  // ...
  image::save_buffer_with_format(
    output.name,
    &output.data,
    output.width,
    output.height,
    image::ColorType::Rgba8,
    image_1_format,
  )
  .unwrap();
  Ok(())
}

Step 12 – Mettere tutto insieme

Questo è il codice finale:

mod args;

use args::Args;
use image::{
  imageops::FilterType::Triangle, io::Reader, DynamicImage, GenericImageView, ImageFormat,
};

fn main() -> Result<(), ImageDataErrors> {
  let args = Args::new();
  println!("{:?}", args);

  let (image_1, image_1_format) = find_image_from_path(args.image_1);
  let (image_2, image_2_format) = find_image_from_path(args.image_2);

  if image_1_format != image_2_format {
    return Err(ImageDataErrors::DifferentImageFormats);
  }

  let (image_1, image_2) = standardise_size(image_1, image_2);
  let mut output = FloatingImage::new(image_1.width(), image_1.height(), args.output);

  let combined_data = combine_images(image_1, image_2);

  output.set_data(combined_data)?;

  image::save_buffer_with_format(
    output.name,
    &output.data,
    output.width,
    output.height,
    image::ColorType::Rgba8,
    image_1_format,
  )
  .unwrap();
  Ok(())
}

enum ImageDataErrors {
  BufferTooSmall,
  DifferentImageFormats,
}

struct FloatingImage {
  width: u32,
  height: u32,
  data: Vec<u8>,
  name: String,
}

impl FloatingImage {
  fn new(width: u32, height: u32, name: String) -> Self {
    let buffer_capacity = 3_655_744;
    let buffer: Vec<u8> = Vec::with_capacity(buffer_capacity);
    FloatingImage {
      width,
      height,
      data: buffer,
      name,
    }
  }
  fn set_data(&mut self, data: Vec<u8>) -> Result<(), ImageDataErrors> {
    if data.len() > self.data.capacity() {
      return Err(ImageDataErrors::BufferTooSmall);
    }
    self.data = data;
    Ok(())
  }
}

fn find_image_from_path(path: String) -> (DynamicImage, ImageFormat) {
  let image_reader = Reader::open(path).unwrap();
  let image_format = image_reader.format().unwrap();
  let image = image_reader.decode().unwrap();
  (image, image_format)
}

fn standardise_size(image_1: DynamicImage, image_2: DynamicImage) -> (DynamicImage, DynamicImage) {
  let (width, height) = get_smallest_dimensions(image_1.dimensions(), image_2.dimensions());
  println!("width: {}, height: {}\n", width, height);
  if image_2.dimensions() == (width, height) {
    (image_1.resize_exact(width, height, Triangle), image_2)
  } else {
    (image_1, image_2.resize_exact(width, height, Triangle))
  }
}

fn get_smallest_dimensions(dim_1: (u32, u32), dim_2: (u32, u32)) -> (u32, u32) {
  let pix_1 = dim_1.0 * dim_1.1;
  let pix_2 = dim_2.0 * dim_2.1;
  return if pix_1 < pix_2 { dim_1 } else { dim_2 };
}

fn combine_images(image_1: DynamicImage, image_2: DynamicImage) -> Vec<u8> {
  let vec_1 = image_1.to_rgba8().into_vec();
  let vec_2 = image_2.to_rgba8().into_vec();

  alternate_pixels(vec_1, vec_2)
}

fn alternate_pixels(vec_1: Vec<u8>, vec_2: Vec<u8>) -> Vec<u8> {
  let mut combined_data = vec![0u8; vec_1.len()];

  let mut i = 0;
  while i < vec_1.len() {
    if i % 8 == 0 {
      combined_data.splice(i..=i + 3, set_rgba(&vec_1, i, i + 3));
    } else {
      combined_data.splice(i..=i + 3, set_rgba(&vec_2, i, i + 3));
    }
    i += 4;
  }

  combined_data
}

fn set_rgba(vec: &Vec<u8>, start: usize, end: usize) -> Vec<u8> {
  let mut rgba = Vec::new();
  for i in start..=end {
    let val = match vec.get(i) {
      Some(d) => *d,
      None => panic!("Index out of bounds"),
    };
    rgba.push(val);
  }
  rgba
}

Fai il build del binario:

$ cargo build --release

E crea un'immagine combinata usando le immagini freeCodeCamp/Rust-In-Replit:

$ ./target/release/combiner images/pro.png images/fcc_glyph.png images/output.png

Ed ecco il risultato in images/output.png:

Output combined image

Conclusione

Con questo, conosci le basi di Rust, ma c'è ancora tanto da imparare.

Continua a seguirci per altri contenuti 😉.