10 unobvious benefits of using Rust

    Rust is a young and ambitious system programming language. It implements automatic memory management without garbage collection and other execution time overhead. In addition, in the Rust language, the semantics of the default movement is used, there are unprecedented rules for accessing the data being changed, and the lifetimes of the links are also taken into account. This allows him to guarantee the safety of memory and facilitates multi-threaded programming, due to the lack of data races.



    All this is already well known to all who are at least a little watching the development of modern programming technologies. But what if you are not a system programmer, and there is not a lot of multi-threaded code in your projects, but you are still attracted by the performance of Rust. Will you get any additional benefits from its use in applied tasks? Or all that he will give you in addition is a bitter struggle with the compiler, which will force you to write a program so that it consistently follows the rules of the language for borrowing and owning?


    This article contains a dozen of unobvious and not particularly advertised advantages of using Rust, which I hope will help you decide on the choice of this language for your projects.


    1. Universality of language


    Despite the fact that Rust is positioned as a language for system programming, it is also suitable for solving high-level applied problems. You do not have to work with raw pointers if this is not necessary for your task. The standard language library has already implemented most of the types and functions that may be needed in application development. You can also easily connect external libraries and use them. The type system and generalized programming in Rust make it possible to use abstractions of a sufficiently high level, although there is no direct support for OOP in the language.


    Let's look at some simple examples of using Rust.


    An example of combining two iterators into one iterator over pairs of elements:


    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]);

    Launch


    Note: A format call name!(...)is a function macro call. The names of such macros in Rust always end with a symbol !so that they can be distinguished from the names of functions and other identifiers. The advantages of using macros will be discussed below.

    An example of using an external library regexfor working with regular expressions:


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

    Launch


    An example of the implementation of the type Addfor its own structure Pointto overload the addition operator:


    use std::ops::Add;
    structPoint {
        x: i32,
        y: i32,
    }
    impl Add for Point {
        typeOutput = Point;
        fnadd(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;

    Launch


    An example of using a generic type in a structure:


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

    Launch


    On Rust, you can write effective system utilities, large desktop applications, microservices, web applications (including the client part, since Rust can be compiled into Wasm), mobile applications (although the ecosystem of the language is poorly developed in this direction). Such versatility can be an advantage for multi-project teams, because it allows the use of the same approaches and the same modules in many different projects. If you are accustomed to the fact that each tool is designed for its narrow scope, then try to look at Rust as a box with tools of the same reliability and convenience. Perhaps this is what you lacked.


    2. Convenient build and dependency management tools


    This is clearly not advertised, but many people notice that Rust implements one of the best build and dependency management systems available today. If you programmed in C or C ++, and the issue of using the external libraries painlessly was acute for you, using Rust with its build tool and Cargo dependency manager would be a good choice for your new projects.


    Besides the fact that Cargo will download dependencies for you and manage their versions, build and run your applications, run tests and generate documentation, in addition it can be extended with plugins for other useful functions. For example, there are extensions that allow Cargo to identify outdated dependencies of your project, perform static analysis of source code, build and redefine client parts of web applications, and more.


    The Cargo configuration file uses the friendly and minimalist markup language toml to describe the project settings. Here is an example of a typical configuration file 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 = "*"

    And below are three typical commands for using Cargo:


    $ cargo check
    $ cargo test
    $ cargo run

    They will be used to check source code for compilation errors, build the project and run tests, build and run the program, respectively.


    3. Built-in tests


    It is so easy and simple to write unit tests in Rust that you want to do it again and again. :) Often it will be easier for you to write a unit test than to try to test the functionality in another way. Here is an example of the functions and tests for them:


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

    Launch


    Functions in a module testmarked with an attribute #[test]are unit tests. They will be executed in parallel when calling a command cargo test. The conditional compilation attribute #[cfg(test)], which marks the entire module with tests, will result in the module being compiled only when executing the tests, and will not fall into the normal assembly.


    It is very convenient to place tests in the same module as the tested functionality, simply by adding a submodule to it test. And if you need integration tests, then simply place your tests in a directory testsin the project root, and use your application in them as an external package. A separate module testand conditional compilation directives in this case do not need to be added.


    The examples of documentation executed as tests deserve special attention, but this will be discussed below.


    Built-in performance tests (benchmarks) are also available, but they are not yet stabilized, so they are only available in the compiler’s nightly builds. In stable Rust for this type of testing will have to use external libraries.


    4. Good documentation with current examples.


    The standard Rust library is very well documented. Html documentation is automatically generated from source code with markdown descriptions in doc comments. Moreover, the dock comments in the Rust code contain code examples that are executed during the test run. This guarantees the relevance of the examples:


    /// 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")]pubfnas_bytes(&self) -> &[u8] {
        &self.vec
    }

    Documentation


    Here is an example of using the as_bytestype methodString


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

    will be executed as a test during the test run.


    In addition, for Rust libraries, it is common practice to create examples of their use in the form of small independent programs located in a directory examplesin the project root. These examples are also an important part of the documentation and they are also compiled and executed during the test run, but they can be run independently of the tests.


    5. Smart type autocast


    In the Rust program, you can not explicitly specify the type of expression if the compiler is able to output it automatically, based on the context of use. And this applies not only to those places where variables are declared. Let's look at this example:


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

    Launch


    If we arrange the type annotations, this example will look like this:


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

    That is, we have a vector of string slices and a variable of type string slice. But in this case, it is completely unnecessary to specify the types, since the compiler can output them himself (using the extended version of the Hindley – Milner algorithm ). The fact that it vecis a vector is already clear from the type of return value from Vec::new(), but it is not yet clear what the type of its elements will be. The fact that a type textis a string slice is understandable by the fact that it is assigned a literal of this type. Thus, the vec.push(text)type of vector elements becomes apparent later . Note that the entire type of the variable vecwas determined by its use in the thread of execution, and not during the initialization phase.


    Such a type auto-derivation system eliminates code noise and makes it as concise as any code in a dynamically typed programming language. And this while maintaining strict static typing!


    Of course, we cannot completely get rid of specifying types in a statically typed language. The program must have points at which the types of objects are guaranteed to be known, so that in other places these types can be displayed. Such points in Rust are declarations of user-defined data types and function signatures, in which the types used cannot be indicated. But you can enter "metavariable types" in them, when using generic programming.


    6. Pattern matching in variable declaration spaces.


    Operation let


    let p = Point::new();

    in fact, it is not limited to declaring new variables. What she actually does is to match the expression to the right of the equal sign with the sample to the left. And new variables can be introduced in the sample (and only this way). Take a look at the following example, and it will become clearer to you:


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

    Launch


    This is a destructuring: this mapping will introduce variables xand ythat will be initialized by the value of the fields xand the ystructure object Pointthat is returned by the call Point::new(). The mapping is correct, since the type of expression on the right Pointcorresponds to the type pattern Pointon the left. Similarly, you can take, for example, the first two elements of an array:


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

    And do a lot more. The most remarkable thing that such comparisons are made in all the places where they can introduce new variable names in Rust, namely, operators match, let, if let, while let, in the header cycle for, the arguments of functions and closures. Here is an example of the elegant use of pattern matching in a loop for:


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

    Launch


    The method invoked enumerateby the iterator constructs a new iterator that will iterate through not the initial values, but the tuples, the "ordinal index, the initial value" pairs. During iterations of the cycle, each of these tuples will be matched with the specified sample (i, ch), as a result of which the variable iwill receive the first value from the tuple — the index, and the variable ch— the second, that is, the string character. Later in the body of the loop, we can use these variables.


    Another popular example of using a sample in a loop for:


    for _ in0..5 {
        // Тело выполняется 5 раз
    }

    Here we just ignore the iterator value using the pattern _. Because we do not use the iteration number in the loop body. The same can be done, for example, with a function argument:


    fnfoo(a: i32, _: bool) {
        // Второй аргумент никогда не используется
    }

    Or when comparing in the operator match:


    match p {
        Point { x: 1, .. } => println!("Point with x == 1 detected"),
        Point { y: 2, .. } => println!("Point with x != 1 and y == 2 detected"),
        _ => (), // Ничего не делаем во всех остальных случаях
    }

    Launch


    Pattern matching makes the code very compact and expressive, and in the operator matchit is generally indispensable. The operator matchis the operator of the full variational analysis, so you will not be able to accidentally forget to check one of the possible matches for the analyzed expression.


    7. Syntax Extension and User DSL


    The syntax of the Rust language is limited, largely due to the complexity of the type system used in the language. For example, in Rust there are no named arguments of functions and functions with a variable number of arguments. But you can get around these and other limitations with macros. There are two types of macros in Rust: declarative and procedural. With declarative macros you will never have the same problems as with macros in C, because they are hygienic and work not at the level of textual substitution, but at the level of substitution in an abstract syntax tree. Macros allow you to create abstractions at the level of syntax of the language. For example:


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

    In addition to the fact that this macro expands the syntactic possibilities of calling the “function” of printing a formatted string, it will also, in its implementation, check whether the input arguments match the specified format string at compile time, not at run time. Using macros, you can enter a concise syntax for your own project needs, create and use DSL. Here is an example of using JavaScript code inside a Rust program compiled in Wasm:


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

    The macro is js!defined in the package stdweband it allows you to embed a full-fledged JavaScript code into your program (with the exception of strings in single quotes and operators that are not terminated by a semicolon) and use in it objects from Rust code using syntax @{expr}.


    Macros offer tremendous opportunities to adapt the syntax of Rust programs to the specific tasks of a specific subject area. They will save your time and attention when developing complex applications. Not by increasing the runtime overhead, but by increasing the compile time. :)


    8. Auto-generation of the dependent code


    Procedural derive macros in Rust are widely used for automatic implementation of types and other code generation. Here is an example:


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

    Since all these character types ( Copy, Clone, Debug, Default, PartialEqand Eq) of the standard library implemented for the type of the field structure i32, then in general the entire structure of their implementation may be derived automatically. Another example:


    externcrate serde_derive;
    externcrate serde_json;
    use serde_derive::{Serialize, Deserialize};
    #[derive(Serialize, Deserialize)]structPoint {
        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();

    Launch


    Here, with the help of derive macros Serializeand Deserializefrom the library, the serdestructure Pointautomatically generates methods for its serialization and deserialization. Then you can transfer an instance of this structure to various serialization functions, for example, converting it to a JSON string.


    You can create your own procedural macros that will generate the code you need. Or use the many already created macros by other developers. In addition to saving the programmer from writing the template code, macros have the advantage that you do not need to maintain different parts of the code in a consistent state. Let's say that if Pointa third field is added to the structure z, then to ensure its correct serialization, if you derive, you don’t need to do anything more. If we ourselves implement the necessary types for serialization Point, we will have to ensure that this implementation is always consistent with the latest changes in the structure Point.


    9. Algebraic data type


    The algebraic data type, to put it simply, is a composite data type that is a union of structures. More formally, this is a type-sum of product types. In Rust, this type is defined using the keyword enum:


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

    The type of a specific value of a type variable Messagecan only be one of those listed in Messagestructure types. This is either a unit-like structure Quitwithout fields, or one of the tuple structures ChangeColoror Writewith nameless fields, or a regular structure Move. A traditional enumerated type can be represented as a special case of an algebraic data type:


    enumColor {
        Red,
        Green,
        Blue,
        White,
        Black,
        Unknown,
    }

    It is possible to figure out which type really took a value in a specific case using pattern matching:


    let color: Color = get_color();
    let text = match color {
        Color::Red => "Red",
        Color::Green => "Green",
        Color::Blue => "Blue",
        _ => "Other color",
    };
    println!("{}", text);
    ...
    fnprocess_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),
        };
    }

    Launch


    In the form of algebraic data types, Rust implements such important types as Optionand Resultwhich are used to represent the missing value and the correct / erroneous result, respectively. Here is how it is defined Optionin the standard library:


    pubenumOption<T> {
        None,
        Some(T),
    }

    In Rust, there is no null-value, exactly like the annoying errors of unexpected access to it. Instead, where it is really necessary to indicate the possibility of missing a value, it is used Option:


    fndivide(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"),
    }

    Launch


    The algebraic data type is quite a powerful and expressive tool that opens the door to Type-Driven Development. A competently written program in this paradigm imposes on the type system most of the checks on the correctness of its work. Therefore, if you lack some Haskell in everyday industrial programming, Rust can be your outlet. :)


    10. Easy refactoring


    Developed a strict static type system in Rust and an attempt to perform as many checks as possible during compilation, leads to the fact that modifying and refactoring the code becomes quite simple and safe. If, after the changes, the program is assembled, it means that only logical errors remain in it that are not related to the functionality, the check of which was assigned to the compiler. Combined with the ease of adding unit tests to verify the logic, this leads to serious guarantees for the reliability of programs and an increase in programmer's confidence in the correct operation of his code after making changes.




    Perhaps this is all I wanted to talk about in this article. Of course, Rust has many other advantages, and there are a number of drawbacks (some dampness of the language, lack of the usual programming idioms, "non-literary" syntax), which are not mentioned here. If you have something to tell about them - write in the comments. In general, try Rust in practice. And maybe his merits for you will outweigh all his flaws, as happened in my case. And you finally get exactly the set of tools that you have needed for a long time.

    Only registered users can participate in the survey. Sign in , please.

    Do you use Rust in your projects?


    Also popular now: