10 nicht offensichtliche Vorteile der Verwendung von Rust

Rust ist eine junge und ehrgeizige Systemprogrammiersprache. Es implementiert die automatische Speicherverwaltung ohne Garbage Collector und anderen Aufwand für die Ausführung. Darüber hinaus wird die Standardsprache in der Rust-Sprache verwendet, es gibt beispiellose Regeln für den Zugriff auf veränderbare Daten und die Lebensdauer der Verbindungen wird ebenfalls berücksichtigt. Dies ermöglicht ihm die Gewährleistung der Speichersicherheit und erleichtert die Multithread-Programmierung aufgrund des Mangels an Datenrasen.



All dies ist bereits jedem bekannt, der die Entwicklung moderner Programmiertechnologien zumindest ein wenig verfolgt. Aber was ist, wenn Sie kein Systemprogrammierer sind und nicht viele Multithread-Codes in Ihren Projekten enthalten, Sie aber dennoch von der Leistung von Rust angezogen werden? Erhalten Sie zusätzliche Vorteile aus der Verwendung in Anwendungen? Oder alles, was er Ihnen zusätzlich geben wird, ist ein harter Kampf mit dem Compiler, der Sie dazu zwingt, das Programm so zu schreiben, dass es ständig den Regeln der Sprache für Ausleihe und Besitz folgt?


Dieser Artikel hat ein Dutzend nicht offensichtlicher und nicht besonders beworbener Vorteile der Verwendung von Rust gesammelt, die Ihnen hoffentlich bei der Entscheidung über die Wahl dieser Sprache für Ihre Projekte helfen werden.


1. Die Universalität der Sprache


Trotz der Tatsache, dass Rust als Sprache für die Systemprogrammierung positioniert ist, eignet es sich auch zur Lösung von Problemen auf hoher Ebene. Sie müssen nicht mit Rohzeigern arbeiten, es sei denn, Sie benötigen sie für Ihre Aufgabe. Die Standard-Sprachbibliothek hat bereits die meisten Typen und Funktionen implementiert, die möglicherweise für die Anwendungsentwicklung erforderlich sind. Sie können auch problemlos externe Bibliotheken verbinden und verwenden. Das Typsystem und die verallgemeinerte Programmierung in Rust ermöglichen die Verwendung von Abstraktionen auf ziemlich hohem Niveau, obwohl OOP in der Sprache nicht direkt unterstützt wird.


Schauen wir uns einige einfache Beispiele für die Verwendung von Rust an.


Ein Beispiel für die Kombination von zwei Iteratoren zu einem Iterator über Elementpaare:


let zipper: Vec<_> = (1..).zip("foo".chars()).collect(); assert_eq!((1, 'f'), zipper[0]); assert_eq!((2, 'o'), zipper[1]); assert_eq!((3, 'o'), zipper[2]); 

Ausführen


Hinweis: Ein Aufruf des Formatnamens name!(...) ist ein Aufruf eines Funktionsmakros. Die Namen solcher Makros in Rust enden immer mit einem Symbol ! damit sie von Funktionsnamen und anderen Bezeichnern unterschieden werden können. Die Vorteile der Verwendung von Makros werden unten erläutert.

Ein Beispiel für die Verwendung der externen regex Bibliothek zum Arbeiten mit regulären Ausdrücken:


 extern crate regex; use regex::Regex; let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap(); assert!(re.is_match("2018-12-06")); 

Ausführen


Ein Beispiel für die Implementierung des Add für die eigene Point , um den Additionsoperator zu überladen:


 use std::ops::Add; struct Point { x: i32, y: i32, } impl Add for Point { type Output = Point; fn add(self, other: Point) -> Point { Point { x: self.x + other.x, y: self.y + other.y } } } let p1 = Point { x: 1, y: 0 }; let p2 = Point { x: 2, y: 3 }; let p3 = p1 + p2; 

Ausführen


Ein Beispiel für die Verwendung eines generischen Typs in einer Struktur:


 struct Point<T> { x: T, y: T, } let int_origin = Point { x: 0, y: 0 }; let float_origin = Point { x: 0.0, y: 0.0 }; 

Ausführen


Auf Rust können Sie effiziente Systemdienstprogramme, große Desktopanwendungen, Microservices, Webanwendungen (einschließlich des Client-Teils, da Rust in Wasm kompiliert werden kann) und mobile Anwendungen schreiben (obwohl das Sprachökosystem in dieser Richtung noch schlecht entwickelt ist). Diese Vielseitigkeit kann für Teams mit mehreren Projekten von Vorteil sein, da Sie in vielen verschiedenen Projekten dieselben Ansätze und dieselben Module verwenden können. Wenn Sie daran gewöhnt sind, dass jedes Werkzeug für seinen engen Anwendungsbereich ausgelegt ist, versuchen Sie, Rust als Werkzeugkasten mit der gleichen Zuverlässigkeit und Bequemlichkeit zu betrachten. Vielleicht haben Sie genau das vermisst.


2. Praktische Tools für das Build- und Abhängigkeitsmanagement


Dies wird eindeutig nicht angekündigt, aber viele bemerken, dass Rust über eines der besten Build- und Abhängigkeitsmanagementsysteme verfügt, die derzeit verfügbar sind. Wenn Sie in C oder C ++ programmiert haben und die Frage der schmerzlosen Verwendung externer Bibliotheken für Sie ziemlich akut war, ist die Verwendung von Rust mit dem Build-Tool und dem Cargo-Abhängigkeits-Manager eine gute Wahl für Ihre neuen Projekte.


Neben der Tatsache, dass Cargo Abhängigkeiten für Sie herunterlädt und deren Versionen verwaltet, Ihre Anwendungen erstellt und ausführt, Tests ausführt und Dokumentation generiert, kann es auch mit Plugins für andere nützliche Funktionen erweitert werden. Beispielsweise gibt es Erweiterungen, mit denen Cargo die veralteten Abhängigkeiten Ihres Projekts ermitteln, eine statische Analyse des Quellcodes durchführen, Clientteile von Webanwendungen erstellen und erneut bereitstellen kann und vieles mehr.


Die Cargo-Konfigurationsdatei verwendet die benutzerfreundliche und minimale Toml-Markup-Sprache, um die Projekteinstellungen zu beschreiben. Hier ist ein Beispiel für eine typische Konfigurationsdatei von Cargo.toml :


 [package] name = "some_app" version = "0.1.0" authors = ["Your Name <you@example.com>"] [dependencies] regex = "1.0" chrono = "0.4" [dev-dependencies] rand = "*" 

Im Folgenden sind drei typische Befehle für die Verwendung von Cargo aufgeführt:


 $ cargo check $ cargo test $ cargo run 

Mit ihrer Hilfe wird der Quellcode auf Kompilierungsfehler, die Zusammenstellung des Projekts und den Start von Tests sowie die Zusammenstellung und den Start des Programms zur Ausführung überprüft.


3. Eingebaute Tests


Das Schreiben von Komponententests in Rust ist so einfach und unkompliziert, dass Sie es immer wieder tun möchten. :) Oft ist es einfacher, einen Komponententest zu schreiben, als die Funktionalität auf andere Weise zu testen. Hier ist ein Beispiel für Funktionen und Tests für sie:


 pub fn is_false(a: bool) -> bool { !a } pub fn add_two(a: i32) -> i32 { a + 2 } #[cfg(test)] mod test { use super::*; #[test] fn is_false_works() { assert!(is_false(false)); assert!(!is_false(true)); } #[test] fn add_two_works() { assert_eq!(1, add_two(-1)); assert_eq!(2, add_two(0)); assert_eq!(4, add_two(2)); } } 

Ausführen


Die Funktionen im test , die mit dem Attribut #[test] , sind Komponententests. Sie werden parallel ausgeführt, wenn der cargo test aufgerufen wird. Das bedingte Kompilierungsattribut #[cfg(test)] , das das gesamte Modul mit Tests kennzeichnet, führt dazu, dass das Modul nur kompiliert wird, wenn die Tests ausgeführt werden, aber nicht in die normale Assembly gelangt.


Es ist sehr praktisch, die Tests im selben Modul wie die zu testende Funktion zu platzieren, indem Sie einfach das test Submodul hinzufügen. Wenn Sie Integrationstests benötigen, platzieren Sie Ihre Tests einfach im Testverzeichnis im Stammverzeichnis des Projekts und verwenden Sie Ihre Anwendung darin als externes Paket. Ein separates test und Anweisungen zur bedingten Kompilierung müssen in diesem Fall nicht hinzugefügt werden.


Spezielle Beispiele für Dokumentationen, die als Tests ausgeführt werden, verdienen besondere Aufmerksamkeit, dies wird jedoch nachstehend erörtert.


Integrierte Leistungstests (Benchmarks) sind ebenfalls verfügbar, jedoch noch nicht stabil. Daher sind sie nur in Compiler-Night-Assemblys verfügbar. In stabilem Rust müssen Sie für diese Art von Tests externe Bibliotheken verwenden.


4. Gute Dokumentation mit aktuellen Beispielen


Die Standard-Rust-Bibliothek ist sehr gut dokumentiert. Die HTML-Dokumentation wird automatisch aus dem Quellcode mit Markdown-Beschreibungen in den Dock-Kommentaren generiert. Darüber hinaus enthalten die Dokumentkommentare im Rust-Code Beispielcode, der ausgeführt wird, wenn Tests ausgeführt werden. Dies stellt die Relevanz der Beispiele sicher:


 /// Returns a byte slice of this `String`'s contents. /// /// The inverse of this method is [`from_utf8`]. /// /// [`from_utf8`]: #method.from_utf8 /// /// # Examples /// /// Basic usage: /// /// ``` /// let s = String::from("hello"); /// /// assert_eq!(&[104, 101, 108, 108, 111], s.as_bytes()); /// ``` #[inline] #[stable(feature = "rust1", since = "1.0.0")] pub fn as_bytes(&self) -> &[u8] { &self.vec } 

Die Dokumentation


Hier ist ein Beispiel für die Verwendung der as_bytes Methode vom Typ String


 let s = String::from("hello"); assert_eq!(&[104, 101, 108, 108, 111], s.as_bytes()); 

wird beim Teststart als Test ausgeführt.


Darüber hinaus ist es für Rust-Bibliotheken üblich, Beispiele für ihre Verwendung in Form kleiner unabhängiger Programme zu erstellen, die sich im examples im Stammverzeichnis des Projekts befinden. Diese Beispiele sind ebenfalls ein wichtiger Bestandteil der Dokumentation und werden auch während des Testlaufs kompiliert und ausgeführt. Sie können jedoch unabhängig von den Tests ausgeführt werden.


5. Intelligenter automatischer Abzug von Typen


In einem Rust-Programm können Sie den Ausdruckstyp nicht explizit angeben, wenn der Compiler ihn basierend auf dem Verwendungskontext automatisch ausgeben kann. Dies gilt nicht nur für Orte, an denen Variablen deklariert werden. Schauen wir uns ein Beispiel an:


 let mut vec = Vec::new(); let text = "Message"; vec.push(text); 

Ausführen


Wenn wir die Typanmerkungen anordnen, sieht dieses Beispiel folgendermaßen aus:


 let mut vec: Vec<&str> = Vec::new(); let text: &str = "Message"; vec.push(text); 

Das heißt, wir haben einen Vektor von String-Slices und eine Variable vom Typ String-Slice. In diesem Fall ist die Angabe von Typen jedoch völlig redundant, da der Compiler sie selbst ausgeben kann (unter Verwendung der erweiterten Version des Hindley-Milner- Algorithmus). Die Tatsache, dass vec ein Vektor ist, wird bereits durch den Typ des Rückgabewerts von Vec::new() , aber es ist noch nicht klar, welcher Typ seiner Elemente sein wird. Die Tatsache, dass der text ein String-Slice ist, ist dadurch verständlich, dass ihm ein Literal dieses Typs zugewiesen ist. Somit wird nach vec.push(text) die Art der vec.push(text) offensichtlich. Beachten Sie, dass der Typ der vec Variablen vollständig durch ihre Verwendung im Ausführungsthread und nicht in der Initialisierungsphase bestimmt wurde.


Ein solches System der Typinferenz eliminiert das Rauschen des Codes und macht ihn so präzise wie den Code in einer dynamisch typisierten Programmiersprache. Und das unter strikter statischer Typisierung!


Natürlich können wir das Tippen in einer statisch typisierten Sprache nicht vollständig loswerden. Das Programm muss Punkte haben, an denen die Objekttypen garantiert bekannt sind, damit diese Typen an anderen Stellen angezeigt werden können. Solche Punkte in Rust sind Deklarationen von benutzerdefinierten Datentypen und Funktionssignaturen, in denen man nur die verwendeten Typen angeben kann. Sie können jedoch mithilfe der allgemeinen Programmierung "Metavariablen von Typen" eingeben.


6. Mustervergleich an Variablendeklarationspunkten


let Operation


 let p = Point::new(); 

nicht wirklich darauf beschränkt, nur neue Variablen zu deklarieren. Was sie tatsächlich tut, ist, den Ausdruck rechts vom Gleichheitszeichen mit dem Muster links abzugleichen. Und neue Variablen können als Teil der Stichprobe eingeführt werden (und nur so). Schauen Sie sich das folgende Beispiel an, und es wird Ihnen klarer:


 let Point { x, y } = Point::new(); 

Ausführen


Die Destrukturierung wird hier durchgeführt: Ein solcher Vergleich führt die Variablen x und y , die mit dem Wert der x und y Felder des Objekts der Punktstruktur initialisiert werden, der durch Aufrufen von Point::new() . Gleichzeitig ist der Vergleich korrekt, da der Typ des Ausdrucks rechts dem Punktmuster vom Typ Point links entspricht. Auf ähnliche Weise können Sie beispielsweise die ersten beiden Elemente eines Arrays verwenden:


 let [a, b, _] = [1, 2, 3]; 

Und noch viel mehr zu tun. Das Bemerkenswerteste ist, dass solche Vergleiche an allen Stellen durchgeführt werden, an denen neue Variablennamen in Rust eingegeben werden können, nämlich: in der match let , if let , while let if let , in der Kopfzeile der for Schleife, in den Argumenten von Funktionen und Abschlüssen. Hier ist ein Beispiel für die elegante Verwendung des Pattern Matching in einer for Schleife:


 for (i, ch) in "foo".chars().enumerate() { println!("Index: {}, char: {}", i, ch); } 

Ausführen


Die enumerate , die für den Iterator aufgerufen wird, erstellt einen neuen Iterator, der nicht über die Anfangswerte, sondern über Tupel iteriert und "Ordnungsindex, Anfangswert" paart. Jedes dieser Tupel während der Iteration des Zyklus wird dem angegebenen Muster (i, ch) , wodurch die Variable i den ersten Wert vom Tupel erhält - den Index und die Variable ch - den zweiten, dh das Zeichen der Zeichenfolge. Weiter im Körper der Schleife können wir diese Variablen verwenden.


Ein weiteres beliebtes Beispiel für die Verwendung eines Musters in einer for Schleife:


 for _ in 0..5 { //   5  } 

Hier ignorieren wir einfach den Wert des Iterators unter Verwendung des _ Musters. Weil wir die Iterationsnummer im Hauptteil der Schleife nicht verwenden. Das gleiche kann zum Beispiel mit einem Funktionsargument gemacht werden:


 fn foo(a: i32, _: bool) { //      } 

Oder beim Matching in einer match Anweisung:


 match p { Point { x: 1, .. } => println!("Point with x == 1 detected"), Point { y: 2, .. } => println!("Point with x != 1 and y == 2 detected"), _ => (), //        } 

Ausführen


Der Mustervergleich macht den Code sehr kompakt und ausdrucksstark und ist in der match im Allgemeinen unersetzlich. Der match ist ein Operator für die vollständige variable Analyse, sodass Sie nicht versehentlich vergessen können, einige der möglichen Übereinstimmungen für den darin enthaltenen analysierten Ausdruck zu überprüfen.


7. Syntaxerweiterung und benutzerdefiniertes DSL


Die Rostsyntax ist begrenzt, hauptsächlich aufgrund der Komplexität des in der Sprache verwendeten Typsystems. Beispielsweise hat Rust keine benannten Funktionsargumente oder Funktionen mit einer variablen Anzahl von Argumenten. Mit Makros können Sie diese und andere Einschränkungen umgehen. Rust hat zwei Arten von Makros: deklarative und prozedurale. Mit deklarativen Makros haben Sie nie die gleichen Probleme wie mit Makros in C, da sie hygienisch sind und nicht auf der Ebene der Textersetzung, sondern auf der Ebene der Ersetzung im abstrakten Syntaxbaum funktionieren. Mit Makros können Sie Abstraktionen auf der Ebene der Sprachsyntax erstellen. Zum Beispiel:


 println!("Hello, {name}! Do you know about {}?", 42, name = "User"); 

Zusätzlich zu der Tatsache, dass dieses Makro die syntaktischen Funktionen zum Aufrufen der "Funktion" zum Drucken einer formatierten Zeichenfolge erweitert, wird in seiner Implementierung auch überprüft, ob die Eingabeargumente zur Kompilierungszeit und nicht zur Laufzeit mit der angegebenen Formatzeichenfolge übereinstimmen. Mithilfe von Makros können Sie eine präzise Syntax für Ihre eigenen Designanforderungen eingeben, DSL erstellen und verwenden. Hier ist ein Beispiel für die Verwendung von JavaScript-Code in einem Rust-Programm, das in Wasm kompiliert wird:


 let name = "Bob"; let result = js! { var msg = "Hello from JS, " + @{name} + "!"; console.log(msg); alert(msg); return 2 + 2; }; println!("2 + 2 = {:?}", result); 

Makro js! definiert im stdweb Paket und ermöglicht es Ihnen, vollwertigen JavaScript-Code in Ihr Programm einzubetten (mit Ausnahme von Zeichenfolgen und Operatoren in einfachen Anführungszeichen und Operatoren, die nicht mit einem Semikolon vervollständigt sind) und Objekte aus dem Rust-Code mit der Syntax @{expr} .


Makros bieten enorme Möglichkeiten, die Syntax von Rust-Programmen an die spezifischen Aufgaben eines bestimmten Themenbereichs anzupassen. Sie sparen Zeit und Aufmerksamkeit bei der Entwicklung komplexer Anwendungen. Nicht durch Erhöhen des Laufzeitaufwands, sondern durch Erhöhen der Kompilierungszeit. :) :)


8. Automatische Generierung von abhängigem Code


Rusts prozedurale Ableitungsmakros werden häufig verwendet, um Merkmale und andere Codegenerierungen automatisch zu implementieren. Hier ist ein Beispiel:


 #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] struct Point { x: i32, y: i32, } 

Da alle diese Typen ( Copy , Clone , Debug , Default , PartialEq und Eq ) aus der Standardbibliothek für den i32 der i32 Struktur implementiert sind, kann ihre Implementierung automatisch für die gesamte Struktur als Ganzes angezeigt werden. Ein weiteres Beispiel:


 extern crate serde_derive; extern crate serde_json; use serde_derive::{Serialize, Deserialize}; #[derive(Serialize, Deserialize)] struct Point { x: i32, y: i32, } let point = Point { x: 1, y: 2 }; //  Point  JSON . let serialized = serde_json::to_string(&point).unwrap(); assert_eq!("{\"x\":1,\"y\":2}", serialized); //  JSON   Point. let deserialized: Point = serde_json::from_str(&serialized).unwrap(); 

Ausführen


Hier werden unter Verwendung der Deserialize und Deserialize aus der serde Bibliothek für die Point Struktur automatisch Methoden für deren Serialisierung und Deserialisierung generiert. Anschließend können Sie eine Instanz dieser Struktur an verschiedene Serialisierungsfunktionen übergeben, z. B. indem Sie sie in eine JSON-Zeichenfolge konvertieren.


Sie können Ihre eigenen prozeduralen Makros erstellen, die den benötigten Code generieren. Oder verwenden Sie die vielen bereits erstellten Makros anderer Entwickler. Makros ersparen dem Programmierer nicht nur das Schreiben von Boilerplate-Code, sondern haben auch den Vorteil, dass Sie nicht verschiedene Codeabschnitte in einem konsistenten Zustand halten müssen. Wenn beispielsweise der Punktstruktur ein drittes Feld z hinzugefügt wird, müssen Sie nichts anderes tun, um die Serialisierung korrekt durchzuführen, wenn Sie ableiten verwenden. Wenn wir selbst die notwendigen Merkmale für die Serialisierung von Point implementieren, müssen wir sicherstellen, dass diese Implementierung immer mit den neuesten Änderungen in der Struktur von Point übereinstimmt.


9. Algebraischer Datentyp


Einfach ausgedrückt ist ein algebraischer Datentyp ein zusammengesetzter Datentyp, der eine Vereinigung von Strukturen darstellt. Formal ist es eine Typensumme von Produkttypen. In Rust wird dieser Typ mit dem Schlüsselwort enum definiert:


 enum Message { Quit, ChangeColor(i32, i32, i32), Move { x: i32, y: i32 }, Write(String), } 

Der Typ eines bestimmten Werts einer Variablen vom Typ Message kann nur einer der in Message aufgelisteten Strukturtypen sein. Dies ist entweder eine einheitliche feldlose Quit Struktur, eine der ChangeColor oder Write ChangeColor mit namenlosen Feldern oder die übliche Move Struktur. Ein traditioneller Aufzählungstyp kann als Sonderfall eines algebraischen Datentyps dargestellt werden:


 enum Color { Red, Green, Blue, White, Black, Unknown, } 

Mithilfe des Mustervergleichs können Sie herausfinden, welcher Typ in einem bestimmten Fall tatsächlich einen Wert angenommen hat:


 let color: Color = get_color(); let text = match color { Color::Red => "Red", Color::Green => "Green", Color::Blue => "Blue", _ => "Other color", }; println!("{}", text); ... fn process_message(msg: Message) { match msg { Message::Quit => quit(), Message::ChangeColor(r, g, b) => change_color(r, g, b), Message::Move { x, y } => move_cursor(x, y), Message::Write(s) => println!("{}", s), }; } 

Ausführen


In Form von algebraischen Datentypen implementiert Rust so wichtige Typen wie Option und Result , die verwendet werden, um den fehlenden Wert bzw. das korrekte / fehlerhafte Ergebnis darzustellen. So wird Option in der Standardbibliothek definiert:


 pub enum Option<T> { None, Some(T), } 

Rust hat keinen Nullwert, genau wie die nervigen Fehler eines unerwarteten Aufrufs. Stattdessen wird Option verwendet, wenn es wirklich notwendig ist, die Möglichkeit eines fehlenden Werts anzugeben:


 fn divide(numerator: f64, denominator: f64) -> Option<f64> { if denominator == 0.0 { None } else { Some(numerator / denominator) } } let result = divide(2.0, 3.0); match result { Some(x) => println!("Result: {}", x), None => println!("Cannot divide by 0"), } 

Ausführen


Der algebraische Datentyp ist ein leistungsstarkes und ausdrucksstarkes Werkzeug, das die Tür zur typgesteuerten Entwicklung öffnet. Ein kompetent geschriebenes Programm in diesem Paradigma ordnet die meisten Überprüfungen der Richtigkeit seiner Arbeit dem Typensystem zu. Wenn Ihnen in der täglichen industriellen Programmierung ein wenig Haskell fehlt, kann Rust Ihr Outlet sein. :) :)


10. Einfaches Refactoring


Das in Rust entwickelte strikte statische Typsystem und der Versuch, während der Kompilierung so viele Überprüfungen wie möglich durchzuführen, führen dazu, dass das Ändern und Umgestalten des Codes recht einfach und sicher wird. Wenn das Programm nach den Änderungen kompiliert wurde, bedeutet dies, dass nur logische Fehler übrig blieben, die nicht mit der Funktionalität zusammenhängen, deren Überprüfung dem Compiler zugewiesen wurde. In Kombination mit der einfachen Hinzufügung von Komponententests zur Testlogik führt dies zu ernsthaften Garantien für die Zuverlässigkeit von Programmen und zu einer Erhöhung des Vertrauens des Programmierers in den korrekten Betrieb seines Codes nach Änderungen.




Vielleicht ist das alles, worüber ich in diesem Artikel sprechen wollte. Natürlich hat Rust viele andere Vorteile sowie eine Reihe von Nachteilen (etwas Feuchtigkeit in der Sprache, Mangel an vertrauten Programmiersprachen und „nicht literarische“ Syntax), die hier nicht erwähnt werden. Wenn Sie etwas darüber zu erzählen haben, schreiben Sie in die Kommentare. Versuchen Sie Rust im Allgemeinen in der Praxis. Und vielleicht überwiegen seine Vorteile für Sie alle seine Mängel, wie es in meinem Fall passiert ist. Und schließlich erhalten Sie genau die Werkzeuge, die Sie lange Zeit benötigt haben.

Source: https://habr.com/ru/post/de430294/


All Articles