Rust to jeden z najnowszych języków programowania, który zyskuje coraz większą popularność wśród developerów, oferując wyjątkową wydajność oraz bezpieczeństwo pamięci. Jednym z najważniejszych narzędzi, które będziesz używać, jest Cargo, który pełni rolę narzędzia do kompilacji, menedżera pakietów oraz uruchamiacza testów. Przyjrzymy się, jak używać Cargo, jak testować swój kod oraz jakie narzędzia wspierające jego jakość warto wykorzystywać, aby pisać wydajny i czysty kod w języku Rust.

Cargo jest kluczowym narzędziem w ekosystemie Rust. Kiedy tworzysz nowy projekt, Cargo automatycznie generuje strukturę katalogów oraz pliki konfiguracyjne, dzięki czemu możesz skupić się na programowaniu, a nie na ręcznym zarządzaniu projektem. Każdy rozdział książki poprowadzi cię przez tworzenie nowych projektów, które powinny być przechowywane w katalogu „solutions”, gdzie umieścisz swoje rozwiązania. W tym katalogu znajdziesz także foldery z testami, które można łatwo skopiować z repozytorium książki i uruchomić je, by sprawdzić działanie swojego kodu.

Przykład uruchomienia testów w projekcie Rust wygląda następująco:

shell
$ cd command-line-rust/01_hello
$ cargo test

Po pomyślnym uruchomieniu testów, powinieneś zobaczyć komunikat o przejściu wszystkich testów. Jeśli wszystkie testy przejdą pomyślnie, oznacza to, że twoje rozwiązanie jest poprawne.

Testowanie w Rust odbywa się z użyciem wbudowanego narzędzia, które umożliwia uruchomienie testów jednostkowych i sprawdzanie, czy nasz kod działa zgodnie z oczekiwaniami. Każdy projekt w książce jest wyposażony w odpowiednią strukturę testów, dzięki czemu możesz sprawdzić, czy poprawnie zaimplementowałeś rozwiązanie przed przystąpieniem do kolejnych rozdziałów.

Kiedy już stworzysz kod, warto zadbać o jego jakość. W Rustie istnieje narzędzie o nazwie rustfmt, które pozwala na automatyczne formatowanie kodu zgodnie z najlepszymi praktykami i konwencjami. Dzięki rustfmt twój kod będzie estetyczny, łatwiejszy do przeczytania i łatwiejszy do utrzymania. Rustfmt jest narzędziem, które może być zintegrowane z edytorem kodu, np. vim, tak aby automatycznie formatować kod przy każdym zapisie pliku.

Rust oferuje także narzędzie o nazwie Clippy, które jest linterem, sprawdzającym kod pod kątem typowych błędów i niedoskonałości. Dzięki Clippy możesz szybko znaleźć potencjalne problemy w kodzie, takie jak niepotrzebne zmienne, nieoptymalne struktury, czy nieużywane funkcje. Używanie Clippy to świetny sposób na poprawę jakości kodu bez konieczności ręcznego przeszukiwania go w poszukiwaniu błędów. Możesz uruchomić Clippy za pomocą komendy:

ruby
$ cargo clippy

Jeśli Clippy nie wykryje żadnych problemów, to oznacza, że kod jest zgodny z najlepszymi praktykami. Warto jednak pamiętać, że Clippy nie jest narzędziem, które zastępuje testy jednostkowe – raczej uzupełnia je, dbając o detale i wykrywając potencjalne problemy, które mogą być trudne do zauważenia w trakcie pisania kodu.

Warto również pamiętać o aktualizacjach samego języka Rust i jego ekosystemu. Rust to język, który rozwija się w szybkim tempie, a zmiany w jego strukturze i narzędziach mogą wpływać na sposób, w jaki piszemy aplikacje. Jednym z przykładów jest zmiana w narzędziu clap – bibliotece do obsługi argumentów w aplikacjach CLI, która od wersji 4 wprowadziła zmiany w sposobie definiowania parserów argumentów. Z tego względu warto być na bieżąco z aktualnymi wersjami narzędzi i bibliotek, które używamy w swoich projektach, aby uniknąć problemów związanych z niezgodnością wersji.

Każdy rozdział książki może także zawierać zmiany w kodzie wynikające z aktualizacji Rust i jego bibliotek. Często zmiany te dotyczą drobnych, ale ważnych szczegółów, takich jak formatowanie kodu czy zmiany w API bibliotek. Dzięki temu książka dostarcza ci nie tylko konkretne przykłady, ale także pokazuje, jak dostosować się do dynamicznie rozwijającego się ekosystemu Rust.

Zanim przejdziesz do pisania pierwszego kodu w Rust, pamiętaj, że oprócz znajomości narzędzi takich jak Cargo, Clippy, rustfmt, ważne jest zrozumienie filozofii języka. Rust stawia na bezpieczeństwo pamięci i kontrolę nad zasobami, a jednocześnie dąży do wydajności, co czyni go idealnym wyborem do budowy systemów o wysokiej niezawodności, jak np. aplikacje systemowe czy oprogramowanie do obsługi dużych zbiorów danych. Pamiętaj, że Rust zmusza do myślenia o zasobach i ich zarządzaniu w sposób, który chroni przed typowymi błędami, takimi jak wycieki pamięci.

Kiedy już rozpoczniesz pracę z Rustem, nie zapomnij o regularnym uruchamianiu testów i linterów, a także o zapoznaniu się z dokumentacją i społecznością Rust. To pomoże ci nie tylko rozwiązywać napotkane problemy, ale również pozwoli ci lepiej zrozumieć narzędzia i filozofię tego języka.

Jak obsługiwać różnice między systemami operacyjnymi w testach w języku Rust

W katalogu tests/expected znajdują się pary plików dla każdego testu. Oznacza to, że dla testu o nazwie name_a istnieją dwa możliwe pliki wyjściowe: jeden dla systemu Unix, a drugi dla systemu Windows. Na przykład:

bash
$ ls tests/expected/name_a.txt*
tests/expected/name_a.txt tests/expected/name_a.txt.windows

Test name_a wygląda następująco:

rust
#[test]
fn name_a() -> Result<()> {
run(&["tests/inputs", "-n", "a"], "tests/expected/name_a.txt") }

Funkcja run używa funkcji format_file_name do stworzenia odpowiedniej nazwy pliku. Używam kompilacji warunkowej, aby określić, która wersja funkcji ma zostać skompilowana. Należy zauważyć, że te funkcje wymagają użycia std::borrow::Cow. Kiedy program jest kompilowany na systemie Windows, używana będzie następująca funkcja, która dodaje .windows do oczekiwanej nazwy pliku:

rust
#[cfg(windows)]
fn format_file_name(expected_file: &str) -> Cow<str> { format!("{}.windows", expected_file).into() }

Natomiast, gdy program nie jest kompilowany na systemie Windows, wykorzystywana będzie wersja, która używa podanej nazwy pliku bez modyfikacji:

rust
#[cfg(not(windows))]
fn format_file_name(expected_file: &str) -> Cow<str> { expected_file.into() }

Wskazówka: Użycie std::borrow::Cow oznacza, że na systemach Unix łańcuch znaków nie jest klonowany, natomiast na Windows zwracany jest zmodyfikowany plik jako nowy, posiadany ciąg znaków.

Kolejnym interesującym przypadkiem jest test unreadable_dir, który będzie wykonywany tylko na platformach innych niż Windows. Jego implementacja wygląda tak:

rust
#[test]
#[cfg(not(windows))] fn unreadable_dir() -> Result<()> { let dirname = "tests/inputs/cant-touch-this"; if !Path::new(dirname).exists() { fs::create_dir(dirname)?; } std::process::Command::new("chmod") .args(&["000", dirname]) .status() .expect("failed"); let cmd = Command::cargo_bin(PRG)? .arg("tests/inputs") .assert() .success(); fs::remove_dir(dirname)?; let out = cmd.get_output();
let stdout = String::from_utf8(out.stdout.clone())?;
let lines: Vec<&str> = stdout.split("\n").filter(|s| !s.is_empty()).collect(); assert_eq!(lines.len(), 17);
let stderr = String::from_utf8(out.stderr.clone())?;
assert!(stderr.contains("cant-touch-this: Permission denied")); Ok(()) }

Test ten najpierw definiuje i tworzy katalog, ustawia odpowiednie uprawnienia, aby katalog stał się niedostępny, a następnie uruchamia program find (tu: PRG), sprawdzając, czy nie wystąpił błąd. Katalog zostaje usunięty, aby testy nie miały wpływu na kolejne uruchomienia. Podzielone linie STDOUT są weryfikowane, a liczba linii powinna wynosić 17. Na STDERR powinien pojawić się oczekiwany komunikat o odmowie dostępu.


Przechodząc do kolejnych rozważań, warto spojrzeć na możliwe rozszerzenia programu find. Można by na przykład zaimplementować dwie bardzo przydatne opcje: -max_depth i -min_depth, które umożliwiają kontrolowanie głębokości przeszukiwania struktury katalogów. Można to osiągnąć za pomocą opcji WalkDir::min_depth i WalkDir::max_depth. Kolejną możliwością jest wyszukiwanie plików na podstawie ich rozmiaru, co w narzędziu find ma specjalną składnię umożliwiającą wskazanie plików mniejszych, większych lub dokładnie o określonym rozmiarze, na przykład:

bash
-size n[ckMGTP]

Ta opcja sprawdza, czy rozmiar pliku (w blokach 512-bajtowych) jest równy wartości n. Można także używać wskaźników skali, takich jak k (kilobajty), M (megabajty), czy G (gigabajty).

Narzędzie find umożliwia również podejmowanie akcji na wynikach, takich jak opcja -delete, która pozwala na usuwanie plików. Na przykład:

bash
$ find . -size 0 -delete

Pomimo że można użyć kombinacji komend, takich jak wc -l, aby policzyć liczbę wyników, warto rozważyć dodanie opcji -count bezpośrednio w programie. Warto również pomyśleć o stworzeniu wersji programu tree w języku Rust, który mógłby wizualizować strukturę plików i katalogów, umożliwiając wyświetlanie jedynie katalogów z opcją -d:

bash
$ tree -d .
├── a │ └── b │ └── c ├── d │ └── e └── f

Program tree obsługuje również możliwość filtrowania plików na podstawie wzorca, używając opcji -P, na przykład:

bash
$ tree -P \*.csv . ├── a │ └── b │ ├── b.csv │ └── c ├── d │ ├── b.csv -> ../a/b/b.csv │ └── e ├── f └── g.csv

Porównanie tej wersji z fd, innym narzędziem Rust do zastępowania find, może dać inspirację, jak można podejść do rozwiązywania podobnych problemów.

Warto zrozumieć, że tego typu programy, mimo swojej prostoty, stają się coraz bardziej złożone, ponieważ muszą obsługiwać różne systemy operacyjne, różne opcje konfiguracji i potencjalne błędy, które mogą wystąpić podczas ich działania. Kiedy pracujemy nad tego typu projektami, istotne jest, aby znać zasady kompilacji warunkowej, używania narzędzi takich jak WalkDir do rekurencyjnego przeszukiwania katalogów, oraz jak działać z plikami tekstowymi i wyrażeniami regularnymi. Na przykład, ważne jest zrozumienie, jak używać wyrażeń regularnych do wyszukiwania wzorców w tekście, jak działa operator ^ w wyrażeniach regularnych (do dopasowania początku linii) oraz $ (do dopasowania końca linii).

Rozważając implementację różnych opcji, warto także zwrócić uwagę na sposoby refaktoryzacji kodu w celu uproszczenia logiki, przy jednoczesnym zachowaniu testów, które zapewniają, że program nadal działa zgodnie z oczekiwaniami.

Jak napisać program w Rust, który działa jak grep?

W tym rozdziale zapiszemy wersję programu w języku Rust, która będzie pełnić rolę narzędzia podobnego do komendy grep, służącego do wyszukiwania wierszy pasujących do określonego wyrażenia regularnego. Domyślnie program będzie przetwarzać dane wejściowe z STDIN, ale możliwe będzie także wskazanie jednej lub więcej nazw plików czy katalogów w celu rekurencyjnego przeszukiwania wszystkich plików w danym katalogu. Zwykłym wyjściem będą linie pasujące do podanego wzorca, jednakże będzie możliwość odwrócenia dopasowania, by znaleźć linie, które nie pasują. Dodatkowo użytkownik będzie mógł zlecić programowi wyświetlenie liczby dopasowanych linii zamiast samych linii tekstu. Domyślnie dopasowanie będzie wrażliwe na wielkość liter, ale dostępna będzie opcja do wykonania wyszukiwania ignorującego wielkość liter. Choć oryginalny program oferuje więcej funkcji, nasza wersja będzie obejmować jedynie te podstawowe.

Jak działa grep?

Zanim zaczniemy pisać program, warto zapoznać się z funkcjonowaniem klasycznego narzędzia grep. Na początek warto rzucić okiem na dokumentację BSD grep, aby zrozumieć, jakie opcje oferuje ten program:

scss
GREP(1) BSD General Commands Manual GREP(1)
NAME grep, egrep, fgrep, zgrep, zegrep, zfgrep -- file pattern searcher SYNOPSIS grep [-abcdDEFGHhIiJLlmnOopqRSsUVvwxZ] [-A num] [-B num] [-C[num]] [-e pattern] [-f file] [--binary-files=value] [--color[=when]] [--colour[=when]] [--context[=num]] [--label] [--line-buffered] [--null] [pattern] [file ...]

Opisuje to możliwość przeszukiwania plików na podstawie określonych wzorców. Warto zwrócić uwagę na parametry takie jak -i, które pozwala na ignorowanie wielkości liter, czy -v, które umożliwia odwrócenie wyników wyszukiwania i wyświetlenie tylko tych linii, które nie pasują do wzorca.

Przykład działania w terminalu

Rozpocznijmy od podstawowego przykładu. Załóżmy, że mamy plik o nazwie fox.txt, który zawiera jeden wiersz tekstu:

sql
The quick brown fox jumps over the lazy dog.

Wykonując następujące polecenie:

perl
grep "fox" fox.txt

Wynikiem będzie:

sql
The quick brown fox jumps over the lazy dog.

Jeśli natomiast przeszukamy plik nobody.txt zawierający wiersze wiersza Emily Dickinsona, w których "Nobody" jest zawsze pisane wielką literą, standardowe wyszukiwanie będzie wyglądać tak:

perl
grep "Nobody" nobody.txt

I zwróci:

rust
I'm Nobody! Who are you? Are you—Nobody—too?

Jeżeli jednak spróbujemy przeszukać tekst z małymi literami:

perl
grep "nobody" nobody.txt

Nic nie zostanie wyświetlone. W tym przypadku należy dodać opcję -i w celu zignorowania wielkości liter:

perl
grep -i "nobody" nobody.txt

Wynik będzie ten sam, co poprzednio:

rust
I'm Nobody! Who are you? Are you—Nobody—too?

Opcja -v pozwala na odwrócenie dopasowania i wyświetlenie wierszy, które nie pasują do wzorca. Na przykład:

perl
grep -v "Nobody" nobody.txt

Zwróci wszystkie linie oprócz tych, które zawierają "Nobody":

pgsql
Then there's a pair of us! Don't tell! they'd advertise—you know!
How dreary—to be—Somebody! How public—like a Frog— To tell one's name—the livelong June— To an admiring Bog!

Aby policzyć liczbę dopasowanych linii, możemy użyć opcji -c:

perl
grep -c "Nobody" nobody.txt

Wynik:

2

Łącząc opcje, możemy np. policzyć linie, które nie pasują do wzorca:

perl
grep -vc "Nobody" nobody.txt

Wynik:

7

Rekurencyjne przeszukiwanie katalogów

Program grep pozwala również na przeszukiwanie wielu plików jednocześnie. Na przykład, używając symbolu *, możemy przeszukać wszystkie pliki tekstowe w bieżącym katalogu:

nginx
grep "The" *.txt

Dzięki temu zostaną wyświetlone pasujące linie z różnych plików, z nazwą pliku przed każdą linią:

makefile
bustle.txt:The bustle in a house
bustle.txt:The morning after death bustle.txt:The sweeping up the heart fox.txt:The quick brown fox jumps over the lazy dog. nobody.txt:Then there's a pair of us!

Jeżeli potrzebujemy przeszukać katalogi rekurencyjnie, używamy opcji -r:

perl
grep -r "The" .

To przeszuka wszystkie pliki w bieżącym katalogu i jego podkatalogach, a wynik będzie wyglądał podobnie do powyższego.

Przygotowanie środowiska w Rust

Aby rozpocząć pisanie programu w Rust, najpierw utwórzmy nowy projekt:

cpp
cargo new grepr

Następnie należy dodać odpowiednie zależności do pliku Cargo.toml:

toml
[dependencies] anyhow = "1.0.79" clap = { version = "4.5.0", features = ["derive"] } regex = "1.10.3" walkdir = "2.4.0" [dev-dependencies] assert_cmd = "2.0.13" predicates = "3.0.4" pretty_assertions = "1.4.0" rand = "0.8.5" sys-info = "0.9.1"

Po tych krokach możemy zacząć implementację naszej wersji programu grep w języku Rust, zachowując jego podstawowe funkcje takie jak wyszukiwanie, ignorowanie wielkości liter, czy rekurencyjne przeszukiwanie katalogów.

Warto pamiętać, że mimo iż nasza wersja programu będzie skupiać się na podstawowych funkcjonalnościach, istnieje wiele zaawansowanych opcji, które oferuje oryginalny program. Dobrze jest znać je, aby w razie potrzeby rozbudować nasz program w przyszłości.

Jak napisać funkcję, która przetwarza pliki w zależności od wyrażenia regularnego?

Podczas pracy z plikami w języku Rust często trzeba analizować zawartość pliku, stosując różnorodne techniki, takie jak dopasowywanie do wyrażeń regularnych. Aby to zrobić efektywnie, warto wykorzystać odpowiednie funkcje, które umożliwią przetwarzanie dużych ilości danych w sposób szybki i niezawodny. Kluczowym aspektem w tym przypadku jest funkcja find_lines, która analizuje plik pod kątem wystąpień danego wzorca wyrażenia regularnego, uwzględniając możliwość odwrócenia dopasowań.

Funkcja find_lines jest zaprojektowana w taki sposób, aby przyjmować trzy argumenty: plik, wzorzec wyrażenia regularnego oraz opcjonalnie flagę, która odwraca działanie funkcji (czyli zamiast dopasowywać linie, które pasują do wzorca, ma znajdować te, które się z nim nie zgadzają). Kluczowym aspektem jest także implementacja błędów, które mogą wystąpić podczas odczytu pliku lub przetwarzania wzorca.

Aby poprawnie zaimplementować funkcję find_lines, należy pamiętać, że plik musi implementować trait BufRead z biblioteki standardowej, co pozwala na efektywne przetwarzanie plików wiersz po wierszu. Dodatkowo wzorzec musi być kompilowanym wyrażeniem regularnym, co zapewnia większą elastyczność w stosowaniu skomplikowanych wzorców. Argument invert jest szczególnie użyteczny w przypadku, gdy chcemy odwrócić wynik dopasowania.

Przykładem użycia tej funkcji jest testowanie na różnych plikach, gdzie sprawdzamy, czy wyrażenie regularne prawidłowo działa w zależności od ustawienia flagi invert. Dzięki temu możemy sprawdzić, czy funkcja działa zgodnie z oczekiwaniami, zarówno dla pozytywnych, jak i negatywnych dopasowań.

W tym kontekście warto zauważyć, że sposób implementacji błędów i obłsugi różnych typów wejściowych ma kluczowe znaczenie dla niezawodności aplikacji. Jeżeli na przykład plik nie istnieje lub jest zablokowany z powodu braku uprawnień, aplikacja powinna odpowiednio zareagować, zamiast po prostu zakończyć działanie. Zatem zarządzanie błędami w programie jest równie ważne, jak sama logika dopasowywania wzorca.

Dodatkowo, w przypadku pracy z większymi zbiorami danych, warto zadbać o optymalizację działania programu. Przy większej liczbie plików lub skomplikowanych wyrażeniach regularnych, czas wykonania funkcji może się znacząco wydłużyć, dlatego dobrze jest stosować techniki takie jak odczyt pliku w małych porcjach lub równoległe przetwarzanie plików.

Rozwój tej funkcji i jej zastosowanie w praktyce może obejmować różnorodne scenariusze, takie jak:

  1. Obsługa wielu plików: Możliwość przetwarzania wielu plików jednocześnie i generowanie raportów na temat liczby dopasowań.

  2. Rekurencyjne przetwarzanie katalogów: Funkcja może być rozwinięta o możliwość rekurencyjnego przetwarzania katalogów, co jest przydatne, gdy dane są rozproszone w wielu folderach.

  3. Zarządzanie błędami dostępu: Oprócz obsługi błędów związanych z otwieraniem plików, warto zaimplementować mechanizmy radzenia sobie z błędami dostępu (np. brak uprawnień lub zablokowany plik).

Biorąc pod uwagę wymogi systemu operacyjnego i możliwe problemy, takie jak błędne zakończenia wierszy (np. CRLF w Windows vs. LF w systemach Unix), dobrze jest także przetestować funkcjonalność w różnych środowiskach, aby upewnić się, że aplikacja będzie działać niezawodnie na różnych platformach.

Warto również wziąć pod uwagę, że dobrym pomysłem jest tworzenie testów jednostkowych, które pomogą wychwycić błędy w implementacji, a także ułatwią utrzymanie kodu. Dzięki testom możemy mieć pewność, że funkcja find_lines działa poprawnie nawet po wprowadzeniu zmian lub aktualizacji zależności.