Når man jobber med programvareutvikling, er det ofte nødvendig å refaktorere kode for å gjøre den mer lesbar, vedlikeholdbar og lett å utvide. En slik refaktorering kan være spesielt viktig når koden inneholder for mye innrykk, komplekse boolske operasjoner og en rekke parenteser som gjør den vanskelig å forstå og videreutvikle. For å gjøre programmet lettere å endre og utvide, er det viktig å omstrukturere koden uten å endre dens funksjonalitet. En effektiv måte å gjøre dette på i Rust er ved å bruke iteratorer og deres funksjoner, som Iterator::filter.

I koden vi ser på her, har vi et behov for å filtrere oppføringer i et filsystem ved hjelp av forskjellige kriterier som type og navn. Koden inneholder flere filtreringsoperasjoner, og måten disse er implementert på kan virke uoversiktlig og vanskelig å forstå. Refaktoreringsprosessen går ut på å finne en enklere og mer intuitiv måte å bruke disse filtrene på, samtidig som koden forblir funksjonell.

For å gjøre dette, kan vi bruke iteratorer og funksjonen Iterator::filter, som gir oss muligheten til å filtrere oppføringer basert på et sett med kriterier uten å måtte skrive omfattende kode. I eksempelet nedenfor bruker vi to funksjoner som håndterer filtrering: én for filtype og én for filnavn. Begge disse funksjonene er implementert som lukkede funksjoner, slik at de kan fange verdier fra argumentene som blir sendt inn til funksjonene.

rust
fn run(args: Args) -> Result<()> { let type_filter = |entry: &DirEntry| { args.entry_types.is_empty()
|| args.entry_types.iter().any(|entry_type| match entry_type {
EntryType::Link => entry.
file_type().is_symlink(), EntryType::Dir => entry.file_type().is_dir(), EntryType::File => entry.file_type().is_file(), }) }; let name_filter = |entry: &DirEntry| { args.names.is_empty() || args.names.iter().any(|re| re.is_match(&entry.file_name().to_string_lossy())) }; for path in &args.paths { let entries = WalkDir::new(path) .into_iter() .filter_map(|e| match e { Err(e) => { eprintln!("{e}"); None } Ok(entry) => Some(entry), }) .filter(type_filter) .filter(name_filter) .map(|entry| entry.path().display().to_string()) .collect::<Vec<String>>();
println!("{}", entries.join("\n"));
}
Ok(()) }

Koden ovenfor illustrerer hvordan vi kan bruke iteratorens metoder for å bygge opp en funksjonell, men samtidig lesbar og utvidbar løsning. Vi begynner med å definere to lukkede funksjoner som utfører filtreringen basert på type og navn. Deretter bruker vi Iterator::filter_map for å håndtere feil, slik at vi kan skrive ut feil til standardfeil uten å stoppe behandlingen av gyldige resultater. Filtrene på plassene for type og navn brukes deretter på de resterende oppføringene, og til slutt omdannes hver oppføring til en streng som kan vises.

Den store fordelen med denne tilnærmingen er at vi kan utvide filtreringen enkelt. For eksempel kan vi legge til flere filtre som fjerner filer basert på størrelse, modifikasjonstid, eierforhold eller andre egenskaper. Dette ville vært mye vanskeligere å gjøre i den opprinnelige koden, som var mer kompleks og vanskelig å vedlikeholde.

En annen fordel er at bruken av enums i stedet for strenger gjør koden mye sikrere. Når vi bruker en enum for filtyper, kan Rusts kompilator forsikre seg om at alle mulige varianter av enumen er dekket i match-utsagnene. Hvis en variant ikke er dekket, vil kompilatoren stoppe med en feilmelding som gjør det klart hvilken del av koden som mangler. Dette bidrar til å gjøre koden mer robust og lettere å endre i fremtiden. For eksempel, dersom en av match-variantene blir kommentert ut, som i følgende kode, vil kompilatoren stoppe og gi oss beskjed om at en mulig variant ikke er håndtert:

rust
let type_filter = |entry: &DirEntry| {
args.entry_types.is_empty() || args.entry_types.iter().any(|entry_type| match entry_type { EntryType::Link => entry.file_type().is_symlink(), EntryType::Dir => entry.file_type().is_dir(), // EntryType::File => entry.file_type().is_file(), // Feil! }) };

Ved å bruke en enum i stedet for en streng får vi en mye sikrere og mer pålitelig løsning. Rust vil sørge for at vi har dekket alle mulige tilfeller av enumen i match-utsagnene våre.

En annen viktig del av koden er bruken av Iterator::filter og Iterator::map. Ved å bruke disse metodene kan vi kombinere flere operasjoner på et datasett uten å måtte skrive utvidede og komplekse løkker. Dette gir oss en mer kompakt og lesbar kode, samtidig som vi beholder all funksjonalitet og fleksibilitet.

Til slutt er det viktig å merke seg at testen av koden på tvers av operativsystemer kan gi oss uventede resultater. For eksempel på Windows vil et symbolsk lenkefil bli behandlet som en vanlig fil, noe som kan føre til at noen filer blir funnet under --type l, og andre blir funnet under --type f. Dette må tas i betraktning når vi skriver tester og tilpasser koden for forskjellige plattformer. Et eksempel på en testfunksjon som håndterer dette scenarioet kan se slik ut:

rust
fn run(args: &[&str], expected_file: &str) -> Result<()> { let file = format_file_name(expected_file);
let contents = fs::read_to_string(file.as_ref())?;
let mut expected: Vec<&str> = contents.split("\n").filter(|s| !s.is_empty()).collect(); expected.sort(); let cmd = Command::cargo_bin(PRG)?.args(args).assert().success(); let out = cmd.get_output();
let stdout = String::from_utf8(out.stdout.clone())?;
let mut lines: Vec<&str> = stdout.split("\n").filter(|s| !s.is_empty()).collect(); lines.sort(); assert_eq!(lines, expected); Ok(()) }

Med denne tilnærmingen kan vi håndtere både Unix- og Windows-plattformer på en effektiv måte, og sikre at testene våre gir de riktige resultatene.

Hvordan lage et Rust-program som sammenligner to filer ved hjelp av iteratorer og enum-er

I dette kapittelet skal vi utforske hvordan du kan bruke iteratorer, enum-er og Rusts matcheringsfunksjon for å lage et program som sammenligner innholdet i to filer, linje for linje. Målet er å bygge et program som etterligner funksjonaliteten til comm-verktøyet, som sammenligner to filer og skriver ut linjene som finnes i begge filene, eller kun i én av dem.

Når vi jobber med Rust, er det viktig å forstå hvordan man bruker iteratorer effektivt. Iteratorer gjør det enkelt å hente linje for linje fra filer uten å måtte laste hele innholdet i minnet. Dette kan være svært nyttig når man arbeider med store filer. En annen viktig komponent i dette programmet er enum-er, som gir oss muligheten til å representere de ulike kolonnene i output på en oversiktlig måte.

Bruk av Iteratorer og Matcher

For å sammenligne to filer, bruker vi iteratorer til å iterere gjennom hver linje i filene. Dette kan oppnås med metoden Iterator::next(), som returnerer den neste verdien fra iteratoren, eller None når det ikke er flere verdier. Når vi sammenligner linjene, bruker vi cmp()-metoden fra Ord-traitet, som gir oss muligheten til å sammenligne to verdier og få en Ordering (Lik, Mindre, eller Større).

Når vi sammenligner to linjer, kan vi bruke match for å håndtere de tre mulige utfallene: når verdiene er like, når den første er mindre enn den andre, og når den første er større enn den andre. For hvert av disse utfallene velger vi hvilken kolonne som skal skrives ut i output.

Enum for Kolonner

I dette programmet introduserte jeg en enum kalt Column, som representerer kolonnene der verdiene skal skrives ut. Hver variant av enum-en holder en &str-verdi, og vi må bruke livstid-annotasjoner for å sikre at disse verdiene lever så lenge de trengs.

rust
enum Column<'a> {
Col1(&'a str), Col2(&'a str), Col3(&'a str), }

Ved å bruke enum-er på denne måten kan vi enkelt velge hvilken kolonne som skal skrives ut basert på resultatene fra sammenligningen av linjene.

Utskriftshåndtering med Closure

For å håndtere utskriften av resultatene, brukte jeg en closure kalt print. Denne closure-en tar en Column som argument og bestemmer hvilken kolonne som skal fylles basert på inputparametrene.

rust
let print = |col: Column| { let mut columns = vec![]; match col { Col1(val) => { if args.show_col1 { columns.push(val); } } Col2(val) => { if args.show_col2 { if args.show_col1 { columns.push(""); } columns.push(val); } } Col3(val) => { if args.show_col3 { if args.show_col1 { columns.push(""); } if args.show_col2 { columns.push(""); } columns.push(val); } } } if !columns.is_empty() { println!("{}", columns.join(&args.delimiter)); } };

Denne closure-en kontrollerer hvilke kolonner som skal inkluderes i output basert på brukerens preferanser og separatoren som er angitt i programmet.

Hovedlogikken i Programmet

Når vi har etablert enum-en og closure-en for utskriften, kan vi bruke disse i hovedlogikken til programmet. Vi bruker while-løkke for å hente linjer fra begge filene, og når vi har linjer fra begge filene, sammenligner vi dem. Avhengig av resultatet av sammenligningen, skriver vi ut linjen i riktig kolonne.

rust
let mut line1 = lines1.next(); let mut line2 = lines2.next(); while line1.is_some() || line2.is_some() { match (&line1, &line2) {
(Some(val1), Some(val2)) => match val1.cmp(val2) {
Equal => {
print(Col3(val1)); line1 = lines1.next(); line2 = lines2.next(); } Less => { print(Col1(val1)); line1 = lines1.next(); } Greater => { print(Col2(val2)); line2 = lines2.next(); } }, (Some(val1), None) => { print(Col1(val1)); line1 = lines1.next(); } (None, Some(val2)) => { print(Col2(val2)); line2 = lines2.next(); } _ => (), } }

Justering av Output-Format

En interessant funksjon i dette programmet er muligheten til å endre output-separatoren. Dette kan være nyttig når man ønsker å gjøre resultatene lettere å lese, for eksempel ved å bruke en annen separator enn tabulatoren.

bash
cargo run -- -d="--->" tests/inputs/file1.txt tests/inputs/file2.txt --->B a b --->--->c d

Denne fleksibiliteten gir brukeren mulighet til å tilpasse formatet på output etter behov.

Forbedring av Programmet

Selv om denne versjonen av programmet etterligner funksjonaliteten til comm, er det alltid rom for forbedringer. Du kan for eksempel endre programmet slik at det samsvarer med GNU-versjonen av comm, som gir flere alternativer for kolonnemodifikasjon. I GNU-versjonen kan du spesifisere hvilke kolonner som skal vises med flaggene -12 for å vise de to første kolonnene, eller -3 for å skjule den siste kolonnen.

I tillegg kan du utforske hvordan du kan bruke iteratorer og match på en mer effektiv måte i andre programmer, som for eksempel join-programmet, som også krever sammenligning av linjer fra to filer.

Viktige Lærdommer

Ved å skrive dette programmet lærer vi flere viktige konsepter i Rust. Bruken av iteratorer gjør det enkelt å jobbe med store datamengder uten å bruke for mye minne. Vi lærer også hvordan man bruker enum-er til å representere ulike kolonner i output på en strukturert måte. Videre gir oss match og cmp() muligheten til å håndtere forskjellige utfall av sammenligningen på en oversiktlig måte. Ved å bruke closure-er, kan vi på en fleksibel måte kontrollere hvordan resultatene blir skrevet ut.