Czy Rust jest następcą C++? Devdebata
Czy język systemowy od Mozilli faktycznie może dokonać wyłomu w zdominowanych przez C/C++ miejscach, wszędzie tam gdzie liczy się wydajność i bliskość hardware’u/sprzętu? Skąd wziął się fenomen tego języka i dlaczego mimo względnie małej popularności od lat plasuje się na 1. miejscu w kategorii „most loved” StackOverflow? Na te i na wiele innych pytań odpowiedzi znajdziecie w tym artykule.
Założeniem Mozilla Foundation, która w roku 2009 zaczęła sponsorować projekt personalny jednego ze swoich pracowników, było stworzenie języka maksymalnie bezpiecznego, współbieżnego, a jednocześnie nowoczesnego i przyjaznego dla programistów. Od rozpoczęcia pracy do ukazania się pierwszej, oficjalnej stabilnej wersji (Rust 1.0) musiało minąć wiele lat (i wymagało dużego researchu), a nastąpiło to dopiero w maju 2015.
Początkowa składnia języka nawet nie przypomina dzisiejszej, główne założenia jednak pozostały niezmienne. Dzisiaj Rust żyje już własnym życiem jako projekt otwarty, open-source’owy, z jednej strony oderwany od Mozilli, a z drugiej chętnie przez nią używany, między innymi w kluczowych komponentach przeglądarki Firefox.
Ciekawostką może być historia, w której Mozilla podejmowała wysiłki, przepisując duży, przestarzały komponent 'stylów’ odpowiedzialny za renderowanie CSS’ów dla Firefoxa, kiedy to dwie próby z użyciem języka C++ zakończyły się niepowodzeniem, a dopiero użycie Rust’a pozwoliło na zminimalizowanie ryzyka wystąpienia krytycznych podatności i niepożądanych w tego typu projekcie błędów bezpieczeństwa. Więcej info: Implications of Rewriting a Browser Component in Rust oraz Quantum CSS (aka Stylo).
Zalety języka Rust zostały już docenione także przez inne, zarówno małe jak i duże znane firmy z branży m.in. Dropbox, npm, Atlassian, Cloudflare, OVH, Sentry, pełna oficjalna lista.
Sam język i jego zalety względem innych to dzisiaj trochę za mało. Decyzja o wyborze lub zmianie języka programowania w projekcie jest trudna i ryzykowna, a przesądza o niej wiele aspektów niezwiązanych z samym językiem. Nowo powstały język programowania może być przełomowy, jednak doświadczenie wielu startupów, które upadły wybierając egzotyczny język programowania, uczy, że również kosztowna, chociażby z braku dostępnych potencjalnych pracowników na rynku pracy, niezbędnych do rozwoju i utrzymania kodu.
Devdebatę przygotował Damian Babula, Software Developer w Telestream.
W devdebacie udział wzięli:
- Bartłomiej Kuras. Senior Rust Developer w Luxoft, a po godzinach współorganizator Rust Wrocław i konferencji Rusty Days. Zajmuje się implementacją rdzenia sieci 5g. W przeszłości programista C++, dzisiaj cały swój wysiłek wkłada w promowanie idiomów Rustowych i samego języka. Żywo zainteresowany poznawaniem nowych technologii twierdzi, żę nowy jezyk zawsze warto poznać choćby po to, żeby zobaczyć inny punkt widzenia.
- Paweł Romanowski. Senior backend engineer w Sauce Labs. Zajmuje się usprawnianiem i rozszerzaniem centralnych części platformy testowej Sauce. Programista Pythona z rozległym doświadczeniem w wielu projektach. Miłośnik Rusta i pasjonat programowania niskopoziomowego, o wysokiej wydajności. Promotor dobrych praktyk inżynierii oprogramowania.
- Michał Papierski. Programuje w języku Rust i cały swój wysiłek wkłada w rozwój projektów Open Source opartych na w/w technologii. W przeszłości fanatyk C++ który zdobywał doświadczenie w dużych korporacjach takich jak Samsung i Amazon. Aktualnie rozwija swoją własną firmę konsultingową IT gdzie również promuje Rusta. W wolnych chwilach śledzi rozwój swojego ulubionego języka i jest zafascynowany jego dynamizmem i kierunkiem w którym on podąża.
Spis treści
1. Czy program napisany w Rust jest porównywalny wydajnościowo do tego napisanego w C/C++?
Odpowiada Bartłomiej Kuras, Rust SW Developer in Metaswitch via Luxoft:
Pytanie nie powinno brzmieć “czy”, ale “dlaczego?”. Na “czy” odpowiadają benchmarki – RustTLS okazuje się być szybszy od OpenSSL, ripgrep przebija grepa (choć może trudno porównywać narzędzia o tak różnej filozofii działania).
Rust przede wszystkim bazuje na praktykach bardziej przyjaznych dla optymalizatora. Nie powinno nikogo dzisiaj zaskoczyć, że w C++ zbudowanie stringa używając IIFE i posługując się już dalej w kodzie zmienną oznaczoną jako const, uzyskujemy zysk performance – ponieważ kompilator ma dodatkową gwarancję. W Ruscie wszystko jest domyślnie stałe, a nawet jeśli stworzyliśmy zmienną mutowalną, można w bardzo prosto powiedzieć “od tego miejsca mój String nigdy się nie zmienia” – nie potrzeba do tego żadnych sztuczek. Także fakt, że w Ruscie wszystko jest domyślnie przesuwane w pamięci, a wykonanie kopii musi być prawie zawsze explicite, jest z punktu widzenia performance zaletą.
Będąc przy dobrych praktykach i ich związku z wydajnością trzeba też wspomnieć o polimorfizmie. W językach kompilowanych od dawna rozróżniamy dwa podstawowe typy polimorfizmu – statyczny i dynamiczny. Statyczny polimorfizm jest bardzo szybki, bo w releasowym wydaniu prawdopodobnie nie zostawia w binarce żadnego śladu. Niestety w C++ powoduje wiele trudności:
- implementacja musi siedzieć w nagłówkach (bo taki polimorfizm implementuje się szablonami),
- komunikaty błędów są okropne (co standard próbuje rozwiązać konceptami, które niestety są tylko półśrodkiem),
- decydując się na klasę polimorficzną statycznie, bardzo trudno jest później użyć jej w kontekście dynamicznym (jeśli na przykład taka potrzeba zajdzie w przyszłości, albo tworzymy bibliotekę, która będzie używana w różnych kontekstach).
Powoduje to, że często w C++ dalej widuje się warstwy interfejsów, bo tak po prostu wygodniej – niestety taki kod jest jak cebula: ma warstwy a jak się go kroi to łzy lecą. W Ruscie polimorfizm statyczny i dynamiczny jest rozwiązywany przez to samo narzędzie – Traity, i używa się niemal identycznie – jeśli jest tylko taka możliwość kod generalizujemy w sposób statyczny, a jeśli z jakiś poziomów nie znamy konkretnego typu w trakcie kompilacji, przekazujemy do funkcji polimorficznej szeroki wskaźnik do naszego obiektu i vtable, a kompilator będzie wiedział co z tym zrobić.
Kolejna ważna rzecz, to jest Zero Overhead Rule. Tu takie dwa przykłady, które Rust po prostu robi lepiej. Pierwszy to Zero Sized Type. W C++ generalnie każdy stworzony obiekt stworzony na stosie musi zajmować minimum jeden bajt, bo ktoś może chcieć trzymać do niego wskaźnik. Nie jest tak w Ruscie – jeśli typ jest faktycznie pusty, to nie ma rozmiaru, a wszelkie referencje (czy wg Rustowej nomenklatury – pożyczki), są wyłącznie semantyczne.
Dużo ciekawszy będzie jednak przykład drugi – co się dzieje, kiedy na skutek dodania do wektora kolejnego elementu następuje jego realokacja? Zarówno w C++ co w Ruscie, musi istnieć gwarancja, że po zakończonej realokacji, wektor przechowuje dane którym można ufać. Niestety w C++ pojawia się problem wyjątku, który może polecieć z konstruktora przenoszącego. Z tego też powodu, jeśli typ trzymany w wektorze nie posiada konstruktora przenoszącego oznaczonego jako noexcept, wektor zamiast przesuwać elementy do nowego bufora, musi je najpierw skopiować, a następnie po udanym kopiowaniu może już usunąć źródłowy bufor wołając destruktory wszystkich obiektów (w przypadku jeśli poleci wyjątek w którymś z konstruktorów kopiujących, kopiowanie jest przerywane, a obiekt nie jest wstawiony do wektora).
Teraz zadanie do wszystkich czytelników – zapraszam do utrzymywanego kodu w C++ i sprawdzamy, czy dla wszystkich typów mamy zaimplementowany odpowiedni konstruktor przenoszący. W Ruscie przenoszenie obiektów w pamięci nie może się nie powieść na poziomie języka, bo jest to zawsze zwykłe przenoszenie danych, a żadne destruktory nie są nigdy wołane – mamy więc gwarancję wydajnej realokacji.
Choć ta odpowiedź jest przydługa, to chcę poruszyć jeszcze jeden, prawdopodobnie najważniejszy temat – obsługa pamięci. O tym, żeby nie używać new/delete w C++ słyszeli wszyscy. Pytanie – co w zamian? Bardzo często widzę, że w zamian jest std::shared_ptr. Dobrze o tyle, że nie będzie double free, źle o tyle, że każde kopiowanie takiego obiektu jest bardzo drogie (w porównaniu do kopiowania wskaźnika). W Ruscie cały pomysł na obsługę pamięci został przedefiniowany razem z wprowadzeniem borrow checkera. Dzięki niemu, możemy używać w Ruscie pamięci w sposób bardzo bezpieczny, nie narażać się nie tylko na double free, ale także na wszelkie błędy z rodziny naruszenia ochrony pamięci, jednocześnie niemal zapominając o zliczaniu referencji.
I też proszę nie zrozumieć mnie źle – czasem zliczenia referencji jest dobrym rozwiązaniem, stąd w Ruscie posiadamy typy Rc i Arc (jest nawet działający garbage collector dostarczany przez bibliotekę crossbeam!), ale są to rozwiązania po które sięga się znacznie rzadziej niż po ich odpowiedniki z C++ – wtedy, kiedy faktycznie z punktu widzenia logiki aplikacji zasób nie jest własnością nikogo konkretnego, co zdarza się naprawdę rzadko.
Odpowiada Paweł Romanowski, Senior Backend Engineer w Sauce Labs:
Wydajnościowo Rustowi najbliżej do C i C++ właśnie i często się robi takie porównania. Jeśli chcemy konkretnych przykładów, to Rust bardzo dobrze wypada w The Computer Language Benchmarks Game hostowanym przez zespół Debiana. Jest to zestaw benchmarków syntetycznych ścierających ze sobą najlepsze możliwe (przesyłane przez pasjonatów) implementacje w różnych językach. Są benchmarki, gdzie Rust wypada lepiej niż C i C++ kompilowane z użyciem GCC lub clang, są benchmarki gdzie wypada gorzej. Mamy też benchmarki TechEmpower ścierające ze sobą różne języki i frameworki w zastosowaniach webowych. Tutaj framework actix dla Rusta zajmuje wysokie miejsca w kilku kategoriach. Mamy też inne przykłady, np. programy CLI: ripgrep, fd czy tokei, które pokazują jak szybki może być Rust w porównaniu do odpowiedników pisanych w C/C++. Ogólnie można więc rzec, wydajność Rusta jest “porównywalna” do C++. Jest to zdecydowanie ta sama półka wydajnościowa i mamy prawo oczekiwać bardzo dobrej wydajności.
O tym czemu Rust wypada lepiej lub gorzej w testach jednowątkowych można dyskutować długo, przechodząc przez różnice w generacji kodu wykonywalnego, warstwy pośrednie reprezentacji kodu w kompilatorze, proces optymalizacji, biblioteki standardowe, konstrukty języka… jednak chciałbym zwrócić uwagę na jedną (ogromną moim zdaniem) zaletę Rusta ponad C++: “programowanie współbieżne bez strachu” (“fearless concurrency”). W przypadku programowania współbieżnego w C++ jesteśmy narażeni na całe klasy różnych problemów, których w Ruście po prostu nie ma.
Jeśli zdecydujemy się użyć wątków, nasz program w Ruście po prostu nie skompiluje się jeśli mogłoby wystąpić “data race”. Brzmi to jak magia, ale właśnie tak jest, dzięki “borrow checkerowi”. Rust dba już na etapie kompilacji aby każdy dostęp do pamięci z wątku był zsynchronizowany poprzez konstrukty takie jak mutex, albo że zwyczajnie nie będzie się dostawał do “nie swoich” danych. Mechanizmy te umożliwiają pisanie programów współbieżnych ze znacznie większą dozą pewności, że po prostu będą działać jak powinny. Samo to daje dużo wydajności wszędzie tam gdzie możemy coś zrównoleglić, a możemy to zrobić dość niskim kosztem (mierzonym czasem programisty).
Odpowiada Michał Papierski, Founder w DELTA Solutions:
Oba języki są na tym bardzo podobnym poziomie wydajnościowym. Różnice w wydajności na niekorzyść Rusta przeważnie wynikają z różnych świadomych decyzji podjętych na etapie projektowania języka, a i tak w praktyce można poinstruować kompilator tak, aby generowany kod był jak najszybszy i porównywalny z C++, jeśli zgodzimy się na zrezygnowanie z pewnych elementów.
Podstawowym miejscem, gdzie Rust mógłby tracić na wydajności względem C++, to są gwarancje bezpieczeństwa w czasie wykonywania. Dla przykładu każda próba przekroczenia zakresu liczb kończy się błędem w czasie wykonywania w trybie debugowania, tak aby ułatwić deweloperowi wyłapanie błędów, a w zoptymalizowanym trybie release kompilowane są praktycznie do tego samego kodu natywnego co w przypadku C++ – z tą różnicą, że wszystkie operacje arytmetyczne są zdefiniowane, więc paradoksalnie tam, gdzie C++ mógłby wygenerować “szybszy” kod kosztem poprawności np. w przypadku przepełnienia zakresu zmiennej ze znakiem, to Rust zawsze musi wygenerować kod a wynik jest przewidywalny niezależnie od wejścia programu.
Kolejnym elementem, gdzie Rust może być wolniejszy, to sprawdzanie zakresu w dostępie do tablic. W C++ kod jest przeważnie szybszy, ponieważ kompilator nie musi sprawdzać czy poprawnym jest dobranie się do, przykładowo, setnego elementu w dziesięcioelementowej tablicy. Robiąc coś takiego w tym języku, na pewno dostaniemy się do jakiegoś obszaru pamięci, w którym coś może się znajdować, ale wynik takiego programu jest niezdefiniowany – przy odrobinie szczęścia możemy np. dostać się do komórki w pamięci która zawiera dane wrażliwe. W Rust program przy próbie wyjścia poza zakres danej tablicy panikuje w trakcie wykonywania.
Tam gdzie Rust może zdecydowanie zyskać na wydajności, to tzw. data oriented design. Język udostępnia bardzo rozbudowane konstrukcje jak np. enumeracje i pattern matching które praktycznie nie istnieją w C++, a które pozwalają kompilatorowi na wygenerowanie kodu lepiej zorganizowanego w pamięci.
Warto również zaznaczyć, że sam kompilator języka Rust używa tych samych mechanizmów “pod spodem” co jeden z najpopularniejszych i najbardziej zaawansowanych kompilatorów C++ – Clang. Oba oparte są na LLVM, i korzystają z podobnych mechanizmów optymalizacji kodu.
2. Sama składnia języka Rust jest zbliżona to C++, Rust posiada również makra, które owiane zostały złą sławą w C++. Czym różnią się makra Rust’a od makr preprocesora z C/C++ i czy powielają te same problemy?
Odpowiada Bartłomiej Kuras, Rust SW Developer in Metaswitch via Luxoft:
Makra w Ruscie i w C++ łączy właściwie jedna cecha – są to mechanizmy do generowania kodu przed kompilacją. Tu jednak podobieństwa się kończą.
Makra w C/C++ owiane są złą sławą przede wszystkim dla tego, że mogą być bardzo niebezpieczne – w przypadku kolizji nazw między makrem a symbolem, czy nawet słowem kluczowym, konsekwencje są fatalne – napotkany token jest zawsze rozwijany jako makro. Wielu słyszało o pomysłach typu “#define true false”, które są zabawne w formie żartu, ale niekoniecznie kiedy zrozumie się jakie ten system problemy za sobą niesie.
W Ruscie makra są rozwiązane w inny sposób. Po pierwsze, samo wywołanie makr ma swoją osobną składnie (wszystkie makra kończą się “!”), dzięki czemu nie da się pomylić zawołania makra z zawołaniem funkcji czy otwarciem pętli for, a od edycji 2018 makra podlegają nawet wszystkim zasadom enkapsulacji w modułach. Po drugie, w Ruscie makra to nie jest pełnotekstowa podmiana symboli. Na wejściu makro zawsze dostaje strumień tokenów, które są albo dopasowywane do szablonu (w tzw. makrach 1.0, czy “makrach poprzez przykłady”), albo parsowane przez autora makra. Dzięki temu makra w Ruscie rozwiązują faktyczny problem, którego nie można rozwiązać standardową składnią – makra pozwalają na tworzenie nowych atrybutów, a nawet DSLi – to dzięki makrom mogą istnieć biblioteki takie jak Serde, która pozwala na zdefiniowanie całkowicie transparentnej dla użytkownika serializacji. Po trzecie, a być może najważniejsze, makra w Ruscie są “higieniczne” – tzn. podlegają większości zasad definiowania zasięgów. Oznacza to, że nie da się napisać makra, które zmienia coś w kontekście w którym zostało wywołane (np. modyfikuje zmienną, która nie została do niego przekazana). Brzmi to zapewne dosyć pogmatwanie – jest mi trudno dobrze to opisać w jednym akapicie – ale zdecydowanie w Ruscie makra nie są niczym złym – dodatkowe, bardzo użyteczne narzędzie języka.
Odpowiada Paweł Romanowski, Senior Backend Engineer w Sauce Labs:
Składnia rzeczywiście jest zbliżona, ale powiedziałbym że Rust ma inny “feeling” niż C++. Ma więcej funkcyjnych i deklaratywnych elementów niż C++ i jest oparty o wyrażenia (“expression based”), tj. niemal każde wyrażenie zwraca wartość.
Co do powielania problemów w makrach: to zależy od problemu, ale ze wskazaniem na “nie” 🙂 W makrach C++, które są de-facto identyczne z makrami w C, mamy w zasadzie zwykłą podmianę ciągów znaków, zanim jeszcze kompilator cokolwiek zrobi (dlatego “makra preprocesora”). To, co takie makro produkuje, może być lub nie być poprawnym składniowo kodem (debugowanie makr w C/C++ jest dość uciążliwe). Makra mogą odnosić się do zmiennych z dowolnego scope i samo to (brak “higieny” makr) jest ogromnym problemem.
Same makra istnieją jednak z dobrego powodu, mianowicie stanowią furtkę do omijania ograniczeń języka i implementacji metaprogramowania. W języku tak prostym jak C ma to jak najbardziej sens i użyte poprawnie, choć obarczone problemami, powoduje że kod jest czytelniejszy i ma mniej powtórzeń. Jednym z moich ulubionych przykładów użycia makr w C jest język Python i jego główna pętla. Można polemizować czy C++ powinien mieć makra takie jak C, ale to temat na osobną dyskusję.
W Ruscie makra można pisać na dwa sposoby i oba sposoby się dopełniają. Ale od początku. Mamy dedykowaną składnię do wołania makr (nazwa_makra! — chodzi o wykrzyknik), które woła się jak funkcje. I tu też leży klu “zwykłych” makr w Ruscie. Są higieniczne, tzn dostają argumenty jak funkcje i nie mogą modyfikować nic, co nie zostało do nich przekazane. Działają na zasadzie manipulacji elementami (już zparsowanej) składni języka. Można w nich używać rekurencji. Sprawdzają się zwłaszcza wokół generycznego kodu, by uniknąć powtórzeń lub by udostępnić kilka wariantów wywołań danej funkcji lub konstruktora. Da się też użyć takich makr do tworzenia prostych DSL (Domain Specific Language). Bardzo dobrym i kreatywnym przykładem takiego makra jest makro json! w serde_json.
Jeśli potrzebujemy makr o jeszcze większej “mocy” kolejną opcją są makra proceduralne. Te z kolei dostają i zwracają “strumień tokenów”, co w skrócie pozwala na niemal dowolną modyfikację kodu (podobnie jak makra C, choć na wyższym poziomie abstrakcji, z mocniejszymi gwarancjami). Tutaj niestety już nie ma higieny i możemy sobie strzelić w stopę. Ale za to możliwości też się rozszerzają, np. możemy definiować nowe atrybuty funkcji (myśl: dekoratory w Pythonie) czy “dziedziczenie” (atrybut derive). Wokół makr proceduralnych mamy całą bibliotekę funkcji które ułatwiają nam pracę z nimi, ograniczając ryzyko najczęstszych błędów.
Rust więc daje nam rozsądny wybór: albo “proste” makra z mocnymi gwarancjami albo te “zaawansowane” z dozą potencjalnych problemów. Na szczęście język jest na tyle ekspresywny, że używanie makr nie jest zwykle konieczne, a wszędzie tam gdzie jest (zwłaszcza proceduralnych), istnieją biblioteki, które są dobrze przetestowane i podążają dobrymi praktykami niezrywania z higieną makr.
Warto jeszcze wspomnieć jak Rust rozwiązuje problem kompilacji warunkowej (w zależności od systemu operacyjnego, zestawów instrukcji wspieranych przez procesor, wersji debug/release itp). Nie są potrzebne skomplikowane makra (czy wręcz osobne wersje całych modułów) do tego, aby implementacje różniły się między sobą w zależności od docelowej konfiguracji i systemu (“targetu” kompilacji) – mamy od tego atrybuty (i makro cfg! jeśli nam go potrzeba). Jako, że zostało to ustandaryzowane, nie musimy wynajdywać koła na nowo budując koszmarki oparte na #ifdef w C/C++. Rust wymusza podczas kompilacji aby nasza konfiguracja była spójna, czego nie można powiedzieć o makrach C/C++.
Odpowiada Michał Papierski, Founder w DELTA Solutions:
Makra w języku C i C++ rozwijane są na etapie preprocesora przed właściwą kompilacją kodu źródłowego. Kawałki kodu źródłowego podmieniane są używając specjalnych dyrektyw określających token wejściowy -> tekst wyjściowy.
Zajmując się tylko zamianą tekstu preprocesor nie zna kontekstu podmiany, co może prowadzić do nieoczekiwanych wyników jeśli np. wewnątrz makra zdefiniujmy zmienną, która istnieje już w danym zakresie w miejscu gdzie dane makro jest używane. Rust określa takie działanie mianem makr niehigienicznych i doskonale rozwiązuje ten problem – tam kod generowany przez makra nie konfliktuje w żaden sposób z miejscem gdzie to makro jest używane.
W Rust makra są również elementem języka i etap preprocesora nie istnieje. Dzięki temu mamy pewność, że kod, który makro generuje musi być poprawny składniowo – w przeciwieństwie do C++, gdzie rozwinięciem makra może być cokolwiek, co może wywalić się dopiero na etapie kompilacji.
Konsekwencja generowania poprawnego kodu, jest również to w jaki sposób wejście przekazywane jest do makra. Przy definiowaniu, możemy przyjąć dowolny szereg tokenów języka np. typ, literał, wyrażenie itp. Definicja takiego makra to tak naprawdę zbiór zasad w jaki sposób kompilator może dopasować próbę użycie makra, do kodu wynikowego, które takie makro definiuje.
Mimo że Rust rozwiązuje wszystkie problemy makr z C/C++ to należy mieć na uwadze, że jest to dość potężne narzędzie, które powinno być używane sporadycznie.
Ciekawymi przykładami takich makr w bibliotece standardowej, to np. makro vec!, które może przyjmować wyrażenia, i zwraca nam nowy wektor zainicjowany wartościami, jeśli jednak przekażemy mu dwie wartości oddzielone średnikiem, to pierwsza zostanie użyta jako wartość, a druga jako rozmiar wektora. Wynikiem w tym wypadku będzie wektor n-elementowy zainicjowany przekazaną wartością.
Specjalnym rodzajem makr w języku Rust są również makra proceduralne, gdzie otrzymujemy strumień tokenów, który możemy dowolnie przetworzyć na etapie kompilacji, operując na tokenach.
W szczególności przydatne tutaj jest słowo kluczowe “derive”, dzięki któremu można wygenerować gotową implementację traitu dla danej struktury z pomocą makr proceduralnych. Biblioteka standardowa zawiera gotowe makra i zwalnia nas to z konieczności ręcznej implementacji kodu w naszych strukturach dla takich funkcjonalności jak porównywanie, klonowanie, kopiowanie, i inne.
3. Czy pisząc w Rust, z każdą nową funkcjonalnością jesteśmy zmuszeni wymyślać koło na nowo. Jak wygląda dostępność bibliotek dla języka Rust?
Odpowiada Bartłomiej Kuras, Rust SW Developer in Metaswitch via Luxoft:
Bardzo różnie, zależnie od konkretnej niszy. Spotkałem się z sytuacjami, kiedy faktycznie zabrakło mi konkretnej biblioteki – na przykład do pracy z bardzo konkretnym protokołem zdefiniowanym przez 3gpp. Jest zawsze możliwość użycia biblioteki w C, jednak nie jest to coś, co bym polecał na początek nauki języka – zanim zrozumiemy jak poprawnie pisać kod potencjalnie niebezpieczny (a używanie funkcji z C zawsze jest niebezpieczne), należy dość dobrze zrozumieć sam język. Jakkolwiek takie sytuacje w normalnej pracy są raczej rzadkie, powiedziałbym nawet, że do codziennych zastosowań, Rust ma do zaoferowania więcej niż C++.
Doskonała biblioteka do serializacji Serde to coś o czym w C++ możemy pomarzyć, a dzięki niedawno wprowadzonej składni dla async/await pisanie asynchronicznego kodu sieciowego z tokio jest dużo przyjemniejsze, niż w boost::asio. Także w kwestii choćby napisania RESTowego czy GraphQLowego serwera sieciowego, w Ruscie mamy dostępne całe spektrum wygodnych w użyciu bibliotek – zarówno takich, które są niemal frameworkami do zestawienia dedykowanego RESTowi serwera, co dodającymi taki protokół do większej aplikacji.
Bardzo istotną zaletą Rusta na tym polu jest też fakt, że sam język domyślnie pracuje ze Stringami zakodowanymi w UTF – mnie osobiście przeraża fakt, że w roku 2020 dalej tak trudno w C++ pisać kod, który będzie poprawnie z tym kodowaniem funkcjonował, a wszystkie kursy tego języka mają gdzieś na początku wspominaną tabelę ASCII. Ponadto ponieważ w przeciwieństwie do C++, Rust nie stawia na nieprzemyślaną wieloparadygmatowość – bardzo łatwo jest wskazać w Ruscie kod idiomatyczny, doskonale wiadomo czego używać warto, a czego nie – dzięki temu biblioteki do siebie “pasują” – rzadko się zdarza, żeby trzeba było pisać duże adaptery.
Jest jednak prawdą, że język jest młody i ekosystem nie jest dojrzały. Dla fanów gamedevu mam smutną wiadomość: brak jest dojrzałych silników w tym języku (choć jest to też dobra wiadomość – macie szansę, żeby mieć swój wkład!). Podejrzewam, że takich obszarów jest zdecydowanie więcej, chociaż w obszarach w których sam się poruszam, raczej się z tym problemem nie spotykam.
Odpowiada Paweł Romanowski, Senior Backend Engineer w Sauce Labs:
Sam język osiągnął wersję 1.0 w roku 2015 i musimy mieć to na uwadze. Dla perspektywy, C++ ujrzał światło dzienne w roku 1985 (a C w 1972). Jest trochę do nadgonienia! Społeczność Rusta jednak stworzyła już wiele stabilnych i uznanych bibliotek, aczkolwiek wiadomo, zdarzają się braki. Zwłaszcza wokół integracji z komercyjnymi produktami typu bazy danych czy API specyficznych dla zamkniętych systemów. Trudno wskazać na frameworki, które są “najlepsze” czy stabilne na tyle, by używać ich produkcyjnie. Boli brak dojrzałych bibliotek numerycznych.
Natomiast podstawowe “cegiełki” do budowania systemów zdecydowanie są. Na tempie przybiera async/await i ekosystem wokół asynchronicznego Rusta. Adopcja Rusta postępuje bardzo szybko i słyszy się o wielkich firmach, które już na Ruście polegają lub zaczynają z nim coraz śmielej eksperymentować. To napawa mnie optymizmem, bo znaczy, że braki powinny zostać szybko wypełnione.
W temacie dostępności bibliotek i adopcji lubię przywoływać porównania – np. Go czy Python 3. Język Go osiągnął wersję 1.0 w 2009 roku i dziś nikt nie narzeka na brak bibliotek, początki jednak były toporne. Podobnie z Pythonem 3 (2008 rok), który zerwał z kompatybilnością z Pythonem 2 i przez wiele lat miał problemy z adopcją; wiele firm ciągle utrzymuje kod w Pythonie 2.
Autorzy Rusta bardzo dbają o kompatybilność. Po pierwsze, kompatybilność z C jest wbudowana w język i Rusta można stosunkowo łatwo dodawać do istniejących projektów. Nie musimy więc z niczym zrywać, tylko adoptować Rusta stopniowo i to samo tyczy się bibliotek (wrapowanie bibliotek C/C++ w Ruście). Po drugie, kompatybilność wsteczna i system “edycji” jest bardzo dobrze przemyślany – kod napisany pod Rusta 1.0 ciągle działa. Można używać bibliotek napisanych pod kilka edycji w jednym projekcie. Ta filozofia na pewno przyczynia się do szybkiej adopcji i jestem niemal pewien, że za dwa-trzy lata problem braku bibliotek nie będzie już przeszkodą w adopcji i promocji. W końcu Rust jest językiem przemyślanym “na następne 40 lat”.
Odpowiada Michał Papierski, Founder w DELTA Solutions:
Mówiąc o ekosystemie i bibliotekach warto wspomnieć o sile języka Rust którym przede wszystkim są narzędzia, z którymi jest dostarczany. Jednym z tych narzędzi jest Cargo – scyzoryk szwajcarski, który zajmuje się ściąganiem należności z repozytoriów, zarządzaniem projektami, i budowaniem ich.
Do wyszukiwania paczek służy nam strona crates.io, gdzie trafiają wszystkie pakiety, a jest ich naprawdę dużo. Te najpopularniejsze projekty, które tam się znajdują są dojrzałe i rekomendowane przez społeczność.
Najciekawszym przykładem takiej biblioteki, to “regex” – jest to paczka do obsługi wyrażeń regularnych. Nie jest ona częścią biblioteki standardowej jak w wielu językach, a osobnym projektem który możemy włączyć w projekt. Jest aktualnie jednym z najszybszych implementacji wyrażeń regularnych z gwarancją wykonywania wyrażeń w czasie liniowym. To dużo mówi o dojrzałości języka jak i społeczności.
Nie można zapomnieć również o bardzo rozbudowanej i bibliotece standardowej, która dostarcza nam wiele gotowych rozwiązań, dzięki którym zdecydowanie do większości zastosowań nie musimy wynajdywać koła na nowo.
Rozbudowana i scentralizowana dokumentacja w tym języku to również jedna z oznak dojrzałości całego ekosystemu.
To była pierwsza część devdebaty na temat tego czy Rust zastąpi C++. Już za tydzień (22.07) opublikujemy drugą część dyskusji.
Zdjęcie główne artykułu pochodzi z unsplash.com.