📜 ⬆️ ⬇️

Rust and Blab's Paradox

A few weeks ago, I came across a comparative analysis of Rust, D and Go from Andrei Alexandrescu. Andrew, a respected member of the C ++ community and the main developer of the D programming language , delivered a crushing blow at the end of his narration, making something that looks like a rather insightful observation:



Reading the code on Rust makes jokes about how "friends do not allow friends to miss the day of the legs" and causes comic images of men with a barbeque torso balancing on skinny legs. Rust puts safety and jewelry handling of memory at the forefront. In fact, this is quite rarely a real problem, and this approach makes the process of thinking and writing code a monotonous and boring process.



After several meetings with Andrew, after seeing some of his speeches, I became convinced that he likes to joke . However, let's swallow the bait. This joke is funny only because it looks funny, or maybe because there is only a joke in it?


')

The paradox of blab


Every time, reflecting on the benefits of certain possibilities of programming languages, I return to Paul Graham 's essay "Beating Mediocrity . " It tells of an interesting phenomenon among programmers, which he calls the “Paradox of Blab”. For those who do not know, the paradox sounds like this: Suppose there is a programmer who uses a certain language, the Blub. In terms of its expressiveness, Blab is somewhere in the middle of a continuum of abstractness among all programming languages. It is not the most primitive, but also not the most powerful programming language.



When our BAB-programmer looks at the "lower" part of the spectrum of programming languages, he easily notices that these languages ​​are less expressive than his favorite Blab. But when our hypothetical programmer looks at the "upper" part of the spectrum, he usually does not realize that he is actually looking up. Here is how Paul describes it:



All he sees is simply "weird" languages. Perhaps he perceives them as equivalent to Blabu, only in them there is still a lot of dumb and incomprehensible garbage. Blab for our programmer is quite enough, since he himself thinks on Blaba.



I remember when I first read it, I thought: “wow, that's pretty insightful.” Who would have thought that years later this concept would firmly take root in my way of thinking when I started trying to teach people how to program.



As a language project manager at Microsoft, I’m working on TypeScript , a typed version of Javascript. Without fail, when I speak to an audience primarily of JavaScript developers and try to convey the idea of ​​how great it would be to try to add a bit of strong typing in Javascript, people look gloomy at me. Every time. Even if it is not required. Even after I describe the half-dozen benefits. As Paul said, it just looks weird. For JavaScript programmers, TypeScript looks basically the same as JavaScript, plus a bunch of dumb and incomprehensible stuff.



After talking with the commands of other programming languages, as well as watching more and more people at conferences, I realized that watching Paul was not only accurate, but also surprisingly universal. Most programmers are ready to fight back with all their might, seeing a new programming language that they never used. New, alien features cause them an allergic reaction. Only after working with new features for quite a long time, they begin to understand that all this is not just useless pribludiny.



In short, the Blab's paradox is something with which we, as programmers, must reckon with, into which we have a tendency to flow, and from which we should get out, putting all our efforts.



Let's just do it. Let's take a look at some of the strangest and most useless features of Rust. And then we will see if we can crank up the deblabization.



Strange garbage number 1. Rust style polymorphism


Let's write a program on Rust, which uses a bit of polymorphism to print two different structures. I’ll show you the code first, and then we’ll cover it in detail.



use std::fmt; struct Foo { x: i32 } impl fmt::Display for Foo { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "(x: {})", self.x) } } struct Bar { x: i32, y: i32 } impl fmt::Display for Bar { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "(x: {}, y: {})", self.x, self.y) } } fn print_me<T: fmt::Display>(obj : T) { println!("Value: {}", obj); } fn main() { let foo = Foo {x: 7}; let bar = Bar {x: 5, y: 10}; print_me(foo); print_me(bar); } 

What is it vyrvlaglaznoe! Yes, there is polymorphism here, but it’s not even close to OOP. This code uses generalizations, and not only generalizations, but there are a lot of limitations in this approach. And what is this impl ?



Let's take in parts. I create two structures to store our values. The next step is to implement something for them called fmt::Display . In C ++, we would overload the << operator for ostream . The result would be the same. Now I can call the print function, passing my structures directly.



This is half the story.



Next we have the print_me function. This function is generalized and takes anything if it can fmt::Display . Fortunately, we have just made sure that our structures can do this.



Everything else is simple. We create several instances of the structures and print them to print_me .



Fuh ... had to work hard. This is how polymorphism is done in Rust. All essence in generalizations.



Now let's switch to C ++ for a moment. Many, especially newbies, could not immediately think of using patterns, and would have followed the path of object-oriented polymorphism:



 #include <iostream> class Foo { public: int x; virtual void print(); }; class Bar: public Foo { public: int y; virtual void print(); }; void Foo::print() { std::cout << "x: " << this->x << '\n'; } void Bar::print() { std::cout << "x: " << this->x << " y: " << this->y << '\n'; } void print(Foo foo) { foo.print(); } void print2(Foo &foo) { foo.print(); } void print3(Foo *foo) { foo->print(); } int main() { Bar bar; bar.x = 5; bar.y = 10; print(bar); print2(bar); print3(&bar); } 

Pretty simple, isn't it? Ok, here's a little quiz for you: what exactly will C ++ code print?



If you have not guessed, do not be discouraged. You are in good company.



If you guessed it - my congratulations! Now think for a minute how much you need to know about C ++ to give the correct answer. From what I see, you need to understand how the stack works, how objects are copied, when they are copied, how pointers work, how links work, how virtual tables are organized, and what dynamic dispatching is. Just to write a few simple lines in OOP style.



When I started learning C ++, this upgrade was too steep for me. Fortunately, my cousin turned out to be a C ++ expert, and, taking me under his wing, he showed me some beaten tracks. Nevertheless, I managed to make tons of children's mistakes, like this example. Why? One of the reasons for the inaccessibility of C ++ is a high cognitive load during its development.



Part of the cognitive load falls on things that are inherent in programming in its essence. You must understand the stack. You need to know how pointers work. But C ++ increases the load, requiring an understanding of when the value will not be completely copied, and when virtual dispatching is used, and when not used - and all this without any warnings from the compiler, if the developer does something, that "most likely is a bad idea."



This is not an attempt to go against C ++. Many things in Rust are implemented with the idea of ​​preserving the philosophy of low-level and efficient abstractions taken from C ++. You can even write code that will be very similar to the Rust example .



What Rust is really doing is separating inheritance from polymorphism, pushing you to think in the direction of creating generalizations from the very beginning. In this way, you begin to think in general from the first day. However, separating inheritance from polymorphism may seem like a strange idea, especially if you are used to always using them together.



Such a separation can cause one of the first manifestations of the Blab effect: what is the general advantage of separating inheritance and polymorphism? And by the way, is there any inheritance in Rust?



Believe it or not, at least in Rust 1.6 there are no special tools for inheriting structures. Instead, their functionality is built up outside the structures themselves, with the help of a special concept of language - “types”. Types allow you to add methods, require the implementation of methods, and in every way to equip data structures in existing systems. Also types support inheritance: one type can extend another.



If you dig around a lot, you can notice something else. Rust does not have all the problems we had to worry about in C ++. We can no longer think about how something is lost when a function is called in some way, and what effect virtual dispatching has on our code. In Rust, everything works in the same style, regardless of type. Thus, a whole class of children's mistakes just disappear.



(Note of the translation. - More information about the types can be read in the Russian-language translation of the book "Rust Programming Language . " )



Strange garbage number 2. I mean, no exceptions?


Since we are talking about things that are not in Rust, the next strange garbage will be the absence of exceptions. Isn't that a step backwards? What do we do with errors? Can we forward them up to process everything at once in one place?



Well, it's time to get to know the monads.



Although ... well, kidding, this time you can do without them. In Rust, error handling is much more straightforward. Here is an example of how it looks in practice. First, examples of how the function declaration will look like:



 impl SystemTime { ///     pub fn now() -> SystemTime; ///  ,   ""   pub fn duration_from_earlier(&self, earlier: SystemTime) -> Result<Duration, SystemTimeError>; } 

Notice that the now function simply returns SystemTime and does not have any exceptions, while duration_from_earlier returns a Rsult type, which can be either Duration or SystemTimeError . Thus, you immediately see all the possible outcomes of the functions, both successful and not successful.



But all of these exceptional situations create a mess in the returned values. Who wants to see this in their code? It is great, of course, to always do error checks, but the point of exceptions is precisely that they allow you to handle errors not only locally, but also to forward them to the top, performing processing in one place.



And Rust lets you do the same.



 fn load_header(file: &mut File) -> Result<Header, io::Error> { Ok(Header { header_block: try!(file.read_u32()) }) } fn load_metadata(file: &mut File) -> Result<Metadata, io::Error> { Ok(Metadata { metadata_block: try!(file.read_u32()) }) } fn load_audio(file: &mut File) -> Result<Audio, io::Error> { let header = try!(load_header(file)); let metadata = try!(load_metadata(file)); Ok(Audio { header: header, metadata: metadata }) } 

Although this is not completely obvious, this code uses throwing exceptions. The whole chip in the macro try! . He does a pretty simple thing. It calls a function. If it succeeds, it will hand the result of the calculation to you. If an error occurs instead, try! flashing this error by completing the current function.



This means that if load_header any problems when calling file.read_u32 , the function will return io::Error . Further, the same will happen in load_audio , and the same error will be returned from it. And so on until the caller finally handles the error.



(Note lane. - Read more about error handling can be found in the article on Habré "Error handling in Rust" .)



Strange garbage number 3. Borrow-checker


You know, it's funny. The first thing that many people mention when they talk about Rust is the borrow checker. Moreover, it is often presented as the main feature of Rust, distinguishing it from other programming languages. For example, for Andrew, the borrow checker is the “shrewish torso” of Rust. For me, borrow checker is just another compiler check. Just like the type checking, the borrow checker allows you to catch most of the bugs before they occur during execution. That's all. Of course, at first it may seem like a monstrous thing, but I dare to argue that it’s not that Rust makes you learn some new incomprehensible type system, but that the ability to work with it builds new muscles like you programmer.



So what mistakes does the borrow checker catch, you ask?



Using pointers after freeing memory


Oh yes, the classic situation, first you free up memory, and then use it again. In most cases, this is exactly the reason why programs crash with the frightening “null pointer exception”.



There are a whole bunch of “good practices” C ++ that allow you to avoid use-after-free: using RAII, using references or smart pointers instead of raw pointers, documenting ownership and borrowing relationships in your API, and so on. Everything that, according to Andrew, “makes the process of thinking and writing code a monotonous and boring process.” A team of well-trained C ++ programmers is able to avoid most of the use-after-free errors, doing monotonous and boring work, because such is the price - following all “good practices”, never read and replenish the team only with highly qualified C ++ experts.



Invalid iterators


Have you ever had to modify a container that you iterated into C ++ and get sudden drops of it sometime in the future? I had to. If you have added or removed at least one element from the container, this is enough to require reallocating the container and making your iterator invalid.



I do not often step on this rake, but it still happens from time to time.



Data Race States


In Rust, the data is either shared or variable. If the data can be changed, it cannot be shared between multiple threads, so there is no way to start changing them in two threads at the same time, thereby causing a race condition. If the data is general, they cannot be modified, so you can read them as much as you like from any number of streams.



If you come from the world of C ++ or any other language with many good parallel libraries, such restrictions may seem too strict. Fortunately, this is not the whole story, but it is the basis that gives you a set of simple rules for creating more complex abstractions. The rest of the story is being written right now. An increasing number of parallel-oriented libraries is emerging in the Rust ecosystem. If you are interested in learning more, you can learn the principles of their work.



Ownership tracking


This concept may seem somewhat redundant, but in fact this is exactly what C ++ is constantly fighting. Earlier, I mentioned one of the good practices of “documenting ownership and borrowing relations in your API”. The problem is that this information is stored in the comments, instead of what is directly in the code.



Here is the script for you: you write in C ++ and you need to call the library that someone else wrote. Suppose this is a C library and it takes raw pointers as arguments. Do you have to take care to delete afterwards what you transferred to this library? Or will she take on this responsibility by storing the data in one of her structures? Maybe you call a scripting engine like Ruby? Who then owns the data?



Instead of reading the documentation, Rust allows you to be confident in your expectations, all the time checking the correctness of using the library API using the borrow checker.



And much more


Borrow checker helps to avoid many other errors. For example, it allows you to always count on the fact that any changeable data that you take to the function you write does not affect any external state, and you can safely change them as you see fit.



Incidentally, this opens up broad opportunities for additional optimizations that are difficult to produce in C-like programming languages, because the compiler ensures that any value that has several aliases cannot be changeable, and vice versa - only one name always has a changeable value.



(Note of the translation. - You can read more about the concept of ownership and borrowing in the Russian-language translation of the Rust Programming Language .)



Strange garbage number 4. Rules are needed to break them.


I believe that one of Rust's greatest strengths is its pragmatism. Most of the strict constraints can be circumvented with features such as unsafe and mem::transmute . Borrow checker is not suitable for solving your problems? Not a problem, just turn it off.



(Note. - Strictly speaking, this is not true: in Rust there is no easy way to disable borrow checker. Even inside unsafe blocks, it works at full capacity. But borrow checker checks borrowing rules only for &T and &mut T , while as in unsafe blocks, you also have the opportunity to use the raw *const T and *mut T , which work almost the same way as the pointers from C. Their use is not limited by the rules of borrowing. For more information, see The Rustonomicon: The Dark Arts of Advanced and Unsafe Rust Programming . ” )



This allows you to do everything that you are used to doing in C-like system programming languages. The advantage of Rust is that it is much easier to write code that is safe from the beginning by default, and then add unsafe areas as they are needed. It is much harder to write secure code based on what's inherently insecure.



Although Rust gives you a choice, it pushes you not to shoot yourself in the foot.



So what about the legs?


Going back to his feet, did Rust miss his workouts? Did he get one-sided? Did he focus on the wrong things?



Rust is growing stronger every day, and, fortunately, is well aware of how to do a squat without bending your back. This moment is hard to overestimate. The Rust philosophy has a solid foundation, which means the language will grow and develop.



From the translator: As always, I thank the Russian-speaking Rust community for their help in translating and valuable comments.

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


All Articles