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 everyone who is 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.
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]);
Note: the call of the formatname!(...)
is a call to the function macro. The names of such macros in Rust always end with a symbol!
so that they can be distinguished from function names and other identifiers. The advantages of using macros will be discussed below.
An example of using the regex
external library for working with regular expressions:
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"));
An example of the implementation of the Add
type for its own Point
structure to overload the addition operator:
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;
An example of using a generic type in a structure:
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 };
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.
This is clearly not advertised, but many 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 Cargo.toml
configuration 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.
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:
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)); } }
Functions in the test
module, labeled with the #[test]
attribute, are unit tests. They will be executed in parallel when you call the cargo test
command. 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 the tests in the same module as the tested functionality, simply by adding the test
submodule to it. And if you need integration tests, then simply place your tests in the tests
directory in the project root, and use your application in them as an external package. A separate test
module and 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.
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")] pub fn as_bytes(&self) -> &[u8] { &self.vec }
Here is an example of using the as_bytes
method of the String
type.
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 the examples
directory at the root of the project. 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.
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:
let mut vec = Vec::new(); let text = "Message"; vec.push(text);
If we arrange the type annotations, this example will look like this:
let mut 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 vec
is a vector is already clear from the type of the return value from Vec::new()
, but it is not yet clear what the type of its elements will be. The fact that the type text
is a string slice is understandable by the fact that it is assigned a literal of this type. Thus, after vec.push(text)
, the type of vector elements becomes obvious. Note that the type of the vec
variable was fully determined by its use in the execution thread, 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.
let
operation
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();
This is where the restructuring is done: such a mapping will introduce the variables x
and y
, which will be initialized by the value of the x
and y
fields of the object of the Point
structure, which is returned by calling Point::new()
. In this case, the mapping is correct, since the type of the expression on the right Point
corresponds to the type of Point
on 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 is that such comparisons are made in all places where new variable names can be entered in Rust, namely: in the match
, let
, if let
, while let
operators, in the for
loop header, in the arguments of functions and closures. Here is an example of the elegant use of pattern matching in a for
loop:
for (i, ch) in "foo".chars().enumerate() { println!("Index: {}, char: {}", i, ch); }
The enumerate
method, called by the iterator, constructs a new iterator that will iterate through not the initial values, but the tuples, the "ordinal index, 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 i
will receive the first value from the tuple — the index, and the variable ch
— the second, that is, the character of the string. Later in the body of the loop, we can use these variables.
Another popular example of using a sample in a for
loop is:
for _ in 0..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:
fn foo(a: i32, _: bool) { // }
Or when matching in the match
statement:
match p { Point { x: 1, .. } => println!("Point with x == 1 detected"), Point { y: 2, .. } => println!("Point with x != 1 and y == 2 detected"), _ => (), // }
Pattern matching makes the code very compact and expressive, and in the match
statement it is generally indispensable. The match
operator is a full variable analysis operator, so you will not be able to accidentally forget to check one of the possible matches for the analyzed expression.
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 design 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); return 2 + 2; }; println!("2 + 2 = {:?}", result);
Macro js!
is defined in the stdweb
package and it allows you to embed full JavaScript code into your program (with the exception of strings in single quotes and operators that are not terminated by a semicolon) and use Rust code objects in it using the @{expr}
syntax.
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. :)
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)] struct Point { x: i32, y: i32, }
Since all these types ( Copy
, Clone
, Debug
, Default
, PartialEq
and Eq
) from the standard library are implemented for the field type of the i32
structure, then for the entire structure as a whole, their implementation can be derived automatically. Another example:
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();
Here, using the Serialize
and Deserialize
derive macros from the serde
library for the Point
structure, methods for its serialization and deserialization are automatically generated. 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. For example, if a third field z
is added to the Point
structure, 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 of Point
, then we will have to ensure that this implementation is always consistent with the latest changes in the Point
structure.
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 enum
keyword:
enum Message { Quit, ChangeColor(i32, i32, i32), Move { x: i32, y: i32 }, Write(String), }
The type of a specific value of a variable of the Message
type can only be one of the structure types listed in the Message
. This is either a unit-like Quit
borderless structure, or one of the ChangeColor
or Write
tuple structures with unnamed fields, or the usual Move
structure. A traditional enumerated type can be represented as a special case of an algebraic data type:
enum Color { 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); ... 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), }; }
In the form of algebraic data types, Rust implements such important types as Option
and Result
, which are used to represent the missing value and the correct / erroneous result, respectively. Here is how Option
is defined in the standard library:
pub enum Option<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, Option
used:
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"), }
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. :)
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 faults, as happened in my case. And you finally get exactly the set of tools that you have needed for a long time.
Source: https://habr.com/ru/post/430294/
All Articles