📜 ⬆️ ⬇️

[Translation] Why Go is not so good

Hello! Recently a translation of an article about how TJ Holowaychuk was saying goodbye to Node.js, deciding to move towards Go, was released. At the end of the article there was a link to Will Yager's post on the comparison and criticism of the Go language, which they were asked to translate - in fact, I suggest that you read the results of the translation. I tried more or less to preserve both the verbose writing style inherent in the author and the original breakdown into sentences and paragraphs.
I would be very happy with any constructive comments and suggestions for translation, misprints and / or design, but please remember that the translator’s point of view may not coincide with the position of the author of the translated article.

Why go is not so good


I like Go. I use it for some things (including this blog at the time of this writing). Go comfortable. However, Go cannot be called a good language. Of course, he is not bad, but not good.

You need to be careful in using languages ​​that are not too good, because in the end you can get stuck and have to use them for 20 years [as with PHP - approx. translator].
Below I give a list of my main complaints about Go; some of them are quite common, and some are very rare.

I will also give comparisons with the Rust and Haskell languages (which I consider to be good languages) - in order to show that the problems I’m going to talk about are in fact already solved [in other languages ​​- approx. translator].
')

Generalizations


The essence of the problem

Suppose we want to write code that we could use for a lot of different things. For example, if I write a function for summing a list of numbers, it would be nice if I could use it for lists of floating-point numbers, lists of integers, lists of any other type of elements that can also be summed. It would also be cool if this code provided the same type safety and speed as the individual functions for each type - the function of summation of lists of integers, lists of numbers with floating point, etc.

The correct approach: generalizations with restrictions and parametric polymorphism

I think the best existing implementations of generalizations are those that are present in the Rust and Haskell languages ​​(they can also be called “systems with limited types”). The version from Haskell is called “type classes”, and the variant from Rust is called “treit” [or “admixture” / “mixin”, depending on the translation - approx. translator]. They look like this:

(Rust, version 0.11 )

fn id<T>(item: T) -> T { item } 

(Haskell)

 id :: t -> t id a = a 

In this synthetic example, we defined the function id with a generic parameter that simply returns the parameter passed to it. The cool thing here is that this function works with any type, and not with any one. In both Haskell and Rust, this function stores the type of the passed parameter, provides type safety, and does not create additional overhead during execution due to the support of generics. By analogy, you can write, for example, the function clone .

Generalizations can also be used to define data structures. For example,

(Rust)

 struct Stack<T> { items: Vec<T> } 

(Haskell)

 data Stack t = Stack [t] 

And again static type safety and the absence of performance loss during execution due to the support of generics are provided.

If we want to write a generic function that does something with parameters, we need to somehow tell the compiler that this function can work only with parameters for which these actions are defined. For example, if we want to define a function that would add three parameters and return their sum, we need to explain to the compiler that the parameters must support addition. You can do it like this:

(Rust)

 fn add3<T:Num>(a:T, b:T, c:T) -> T { a + b + c } 

(Haskell)

 add3 :: Num t => t -> t -> t -> t add3 abc = a + b + c 

Here, we kind of say to the compiler: “The parameters of the function add3 can be of any type T , but with the restriction that T must be one of the Num subtypes (numerical type)”. Since the compiler knows that addition is defined for numeric types, the code will pass a type check. Such constraints can also be used in defining data structures. Well, this is a very elegant and easy way for type-safe and extensible programming with generalizations.

Go approach: interface{}

Due to the very mediocre type system, Go has very poor support for generic programming.

Similarities of generic functions are written fairly easily. For example, you would like to write a function that displays the hash of any object that can be hashed. To do this, you can define an interface that allows you to do this with a type safety guarantee, that is, something like

 type Hashable interface { Hash() []byte } func printHash(item Hashable) { fmt.Println(item.Hash()) } 

Now you can pass any implementing interface to a Hashable object, and static type checking will also be performed, which is good in general.

But what happens if you want to define a data structure with generic types? Let's throw a simple LinkedList link type list. Here is a typical way to do this in Go:

 type LinkedList struct { value interface{} next * LinkedList } func (oldNode * LinkedList) prepend(value interface{}) * LinkedList { return &LinkedList{value, oldNode} } func tail(value interface{}) * LinkedList { return &LinkedList{value, nil} } func traverse(ll * LinkedList) { if ll == nil { return } fmt.Println(ll.value) traverse(ll.next) } func main() { node := tail(5).prepend(6).prepend(7) traverse(node) } 

Did you notice anything? Type field value - interface{} . Here, interface{} is what we call the “base type,” which means that all other types inherit from it. This is the direct equivalent of the Object class in Java. Damn it.
(Note: there is some disagreement about whether there is a base type in Go, since Go implies the absence of subtypes. Despite this, the analogy remains.)

The “right” way to build generalized structures in Go is to bring entities to the base type and then add them to the data structure. This is about how it was taken in Java year in 2004. Then people realized that this approach completely breaks the type system. When data structures are used in this way, all the advantages of a strict type system simply evaporate [in fact, I don’t see a big problem here - instead of the basic interface{} you can, in principle, specify a more specific interface, bringing specific implementations to it and not crashing this way types - approx. translator].

For example, here is an absolutely correct code:

 node := tail(5).prepend("Hello").prepend([]byte{1,2,3,4}) 

This deprives a well-structured program of its advantages. For example, the method expects a coherent list of integers as a parameter, but suddenly some tired, persistent coffee programmer with a deadline on the nose suddenly sends a string. And the compiler will not notice anything, because the generalized structures in Go do not know anything about the types of their values, and one day the program will simply fall on the cast to interface{} .

Similar problems can arise with any generalized structures - even with list , map , graph , tree , queue .

Language extensibility


The essence of the problem

High-level languages ​​often have keywords and symbols, which are shortcuts for more complex tasks. For example, in many languages ​​there is a way to bypass all the elements of data collections, at least the same arrays:

(Java):

 for (String name : names) { ... } 

(Python):

 for name in names: ... 

It would be nice if we could use similar syntactic sugar for any collection, and not just for built-in language (like arrays).

It would also be convenient if we could define operations like addition for our types of operation and write things like these:
[Speech, for example, about operator overloading - approx. translator]

 point3 = point1 + point2 

The correct approach: operators are functions

A good decision is to make the operators "labels" to the corresponding functions / methods, and keywords - aliases to the standard functions / methods.

Some languages: Python, Rust, Haskell, etc. - allowed to redefine operators [and Haskell also define its own - approx. translator]. All that needs to be done is to write the class methods, and then when using any operator (for example, " + ") the corresponding method is simply called. In Python, the + operator calls __add__() . In Rust, " + " is defined in the Add as add() method. In Haskell, " + " corresponds to the + function defined in the Num type.

Many languages ​​support a way to extend the scope of different keywords like for-each . There are no loops in Haskell, but in languages ​​like Rust, Java, and Python there are iterators that make it possible to use for-each with any collections of any type.

The downside is that you can redefine the operators so that they will do something completely non-intuitive. For example, bylokloder [orig. "Crazy coder" - approx. translator] can define the " - " operator as multiplication of two vectors, but this is still not the problem of operator overloading itself, since functions can be poorly called in any language.

Go approach: no

Go does not support operator overloading and keyword expansion.

But what if we suddenly want to use the range keyword with something else — a tree or a linked list? Will not work. Language does not support this. You can use range only with embedded structures. It is the same with make - it can be used to allocate memory and initialize only embedded structures.

The closest available analogy of an extensible iterator is to write a wrapper over a data structure that returns chan ( channel - comment of the translator), and then iterate through it, but this is slow, difficult, and can cause a lot of bugs.

Such an approach is justified approximately as follows: “it is easy to understand, and the code that is written to the page is the code that is being executed”. That is, if Go would allow the expansion of range items, this could cause confusion, because the details of the range implementation for a particular case may not be obvious. But for me, this argument means little, because people have to bypass data structures, regardless of whether it makes Go easy and convenient. Instead of hiding the implementation details behind the range , we have to hide the implementation details behind another auxiliary function — not too much like a big improvement. Well-written code is easy to read, and poorly written code is difficult, and, obviously, Go cannot change this.

Basic cases and error checking


The essence of the problem

When working with recursive data structures — linked lists or trees — we want to have a way to show that the end of the structure has not yet been reached.

Using the same functions that can fail, or data structures that may lack any data, we want to be able to show that the task cannot be completed.

Go approach: Nil and multiple return values

I'm going to talk about the Go approach first, because after that it will be easier to explain the right approach.
In Go there is a nil - null pointer . I think it is shameful that such a new and modern language - so to speak, tabula rasa - implements this unnecessary, crutch functionality that leads to bugs.

The null pointer has a long and buggy history. For historical and practical reasons, the data used was almost never stored at 0x0 , so pointers to 0x0 were usually used to represent a particular situation. For example, a function that returns a pointer may return 0x0 if it failed. Recursive data structures can use 0x0 to define some basic case (like, say, the current tree node is a leaf, or that the current linked list item is the last). For all this, a null pointer is used in Go.

However, using a null pointer may be unsafe. In fact, this pointer is a violation of the type system; it allows you to create an instance of a type that is not really a type at all. It is extremely common for a programmer to forget to check the pointer to zero, and this potentially leads to crashes and, in even more terrible cases, to vulnerabilities. The compiler cannot simply take and protect against this, because the null pointer is knocked out of the adopted type system.

To the credit of Go, it is correct and generally preferable to strengthen the mechanism of multiple return of values ​​adopted in Go, returning the second “unsuccessful” value if there is a probability that the function failed. However, this mechanism can easily be ignored or misused, and, as a rule, useless to represent basic cases in recursive data structures.

The correct approach: algebraic data types and type safe failures

Instead of violence over the type system to represent erroneous states or basic cases, we can use the type system to safely hide all of these situations.

Suppose we want to implement a linked list. We want to present two possible cases: first, if the end of the list is not yet reached and the current element has data, and, second, if the end of the list is reached. A type-safe path involves the implementation of two types, respectively representing one of these cases, and then combining them together using algebraic data types. Suppose we have type Cons , representing an element of a linked list with some data, and type End , which is the end of the list. We can write this as follows:

(Rust)

 enum List<T> { Cons(T, Box<List<T>>), End } let my_list = Cons(1, box Cons(2, box Cons(3, box End))); 

(Haskell)

 data List t = End | Cons t (List t) let my_list = Cons 1 (Cons 2 (Cons 3 End)) 

Each type defines a base case ( End ) for any recursive algorithm that performs operations on a data structure. Neither Rust nor Haskell allow null pointers, so we are 100% sure that we will never encounter bugs related to null pointers (of course, as long as we don’t do some crazy low-level things).

These algebraic data types also allow writing incredibly short (and therefore weakly subject to bugs) code due to such features of the language as pattern matching , as will be shown below.

Well, and what should we do if the function can return or not return a value of some type, or if the data structure may or may not contain data? That is, how can we hide the error state in our type system? To solve this problem, Rust has something called Option , and Haskell has something called Maybe .

Imagine a function that looks for a string starting with an 'H' in an array of non-empty strings, and returns the first matching string or some error if such a string is not found. In Go, in case of an error, you would have to return nil . And here is how we can do it safely and without crutches with pointers in Rust and Haskell:

(Rust):

 fn search<'a>(strings: &'a[String]) -> Option<&'a str> { for string in strings.iter() { if string.as_slice()[0] == 'H' as u8 { return Some(string.as_slice()); } } None } 

(Haskell):

 search [] = Nothing search (x:xs) = if (head x) == 'H' then Just x else search xs 

Instead of returning a string or null pointer, we return an object that may or may not contain a string. We never return a null pointer, and developers using search() know that a function can complete successfully or unsuccessfully, because its type says so, and that they must be ready for both. Farewell to the null pointer bugs!

Type inference


The essence of the problem

It becomes a bit old-fashioned to indicate the type of each variable in the program code. There are situations where the type of value is obvious:

 int x = 5 y = x*2 

It is quite clear that y also of type int . Of course, there are more complex situations, for example, the output of a type returned by a function based on the types of its parameters (or vice versa).

The right approach: general type inference

Since both Rust and Haskell are based on the Hindley-Milner type system , they are both very good at type inference, and you can do these cool things:

(Haskell):

 map :: (a -> b) -> [a] -> [b] let doubleNums nums = map (*2) nums doubleNums :: Num t => [t] -> [t] 

Since the function (*2) takes a parameter of type Num and returns a value of type Num , Haskell can determine that the type is both a and b - Num , and from here it can infer that the function accepts and returns a list of values ​​of type Num . It is much more powerful than those simple type inference systems that are supported by languages ​​like Go and C ++. This allows you to make the minimum number of explicit type indications, and the compiler can correctly determine everything else, even in very complex programs.

Go approach: operator :=

Go supports the assignment operator := , which works like this:

(Go):

 foo := bar() 

All it does is output the type returned by the bar() function and assign the same to foo . It turns out about the same as here:

(C ++):

 auto foo = bar(); 

This is not very interesting. All it does is eliminate the two-second effort to look at the type returned by the bar() function, and from writing a few characters of the type name of the variable foo .

Immutability state


The essence of the problem

The immutability of the state (immobility) - the idea, the essence of which is that the values ​​are set only once at the time of creation and then cannot change. The advantages of this approach are very obvious: if the values ​​are unchanged, the possibility of bugs caused by a change in the data structure in one place at the time of use in another place is significantly reduced.

State immutability is also useful for some types of optimization.

The correct approach: default state immutability

Programmers should try to use immutable data structures as often as possible. The immutability of the state makes it much easier to determine the possible side effects and safety of the program, eliminating the whole classes of possible errors.

In Haskell, all values ​​are immutable. If you want to change the state of the data structure, you will have to create another structure with the necessary values. This is still fast, because Haskell uses lazy calculations and persistent data structures . Rust is a system programming language, so it cannot use lazy computations, and state immutability cannot always be as practical as Haskell. However, in Rust, all declared variables are immutable by default [in this case, it is not correct to call them variables, but it was in the original - approx. translator], but there is also the ability to change the state if it is required. And this is great because it forces the programmer to explicitly state that the variable being declared must be variable, and this encourages the use of best programming practices and allows the compiler to optimize better.

Go approach: no

Go does not support state immutability.

Control structures


The essence of the problem

Control Structures [orig. "Control flow structures" - approx. translator] is part of what distinguishes programming languages ​​from machine code. They allow us to use abstractions to control the execution of a program in the right direction. Obviously, all programming languages ​​support some control constructs, otherwise nobody would use them. However, there are several control constructs that I lack in Go.

The Right Approach: Pattern Matching and Compound Expressions

Pattern matching is a really cool way to work with data structures and values. It looks like case / switch on steroids. You can compare with the sample as follows:

(Rust):

 match x { 0 | 1 => action_1(), 2 .. 9 => action_2(), _ => action_3() }; 

You can work with structures in this way:

 deg_kelvin = match temperature { Celsius(t) => t + 273.15, Fahrenheit(t) => (t - 32) / 1.8 + 273.15 }; 

The previous example shows something called “compound expression” [orig. "Compound expressions" - approx. translator]. In languages ​​like C and Go, the if and case / switch simply direct the flow of program execution; they do not calculate values. In languages ​​like Rust and Haskell, the if and pattern matching are able to calculate values ​​that can be assigned to something. In other words, the if and pattern matching can indeed “return” values. Here is an example with an if :

(Haskell):

 x = if (y == "foo") then 1 else 2 

Go Approach: C-free operators

I don't want to humiliate Go right now - it has some valid control structures for certain things like select for parallelization. However, it does not contain compound expressions and pattern matching, which I love so much. Go only supports the assignment of atomic expressions like x := 5 or x := foo() .

Programming for embedded systems


Writing programs for embedded systems is very different from writing programs with full-featured operating systems. Some languages ​​are much better suited for working with special programming requirements for embedded systems.

I’m surprised a lot of people offering Go as a programming language for robots. Go is not suitable for programming for embedded systems for a number of reasons. This section is not about Go criticism, just Go was not designed for this. This section is about the misconceptions of people who recommend writing to Go for embedded systems.

Problem number 1: heap and dynamic memory allocation

A heap is a section of memory that can be used to store an arbitrary number of objects created during execution. Heap usage is called dynamic memory allocation.

It is generally unwise to use a bunch when programming for embedded systems. The main reason is that a heap, as a rule, requires considerable additional memory costs and some very complex structures for management, none of which is suitable when writing under an eight-mega-hertz controller with two kilobytes of RAM.

Also, of course, it’s unwise to use a bunch on real-time systems.(systems that can fail if the operation takes too long), because the amount of time required for allocating and freeing memory on the heap is not deterministic. For example, if your microcontroller controls a rocket engine, it will suck if you try to allocate a certain amount of memory in a heap and it takes a few hundred milliseconds more than usual, and this will lead to untimely adjustment of the valve and a strong explosion.

There are other aspects of dynamic memory allocation that make its use unsuitable for efficient programming for embedded systems. For example, many languages ​​that use a heap rely on the garbage collector, which during work usually suspends program execution to find and remove objects that are no longer used. This makes the program even more unpredictable than just using dynamic memory.

The correct approach: make dynamic memory optional

The standard library Rust language has built on dynamic memory functionality - eg boxes. However, the compiler supports flags to completely disable all dynamic memory-using functionality and to force a static check that this functionality is not used in code. Rust really allows you to write programs without using a heap at all.

Go approach: no

Go is very tied to the use of dynamic memory. There is not a single practical way to force Go code to use only the stack, but for Go it is not a problem - of course, in the areas for which Go is intended.

Go is also not a real-time language. It is absolutely impossible to give strict guarantees of the execution time of any sufficiently large program. This can be a little puzzling, so I will explain: Go is relatively fast, but this is not enough for real time. There is a big difference: speed is important for real time, but much more important is the ability to guarantee maximum response time, which cannot be easily predicted in the case of Go - of course, due to the strong use of the heap and because of garbage collection.

Similar problems arise in the case of languages ​​like Haskell, which are unsuitable for real-time tasks or for programming embedded systems because of the equally large string on the heap. However, I have never seen anyone promoting Haskell as a programming language for robots, so there is no need to discuss this.

Problem number 2: writing unsafe low-level code

When you have to write programs for embedded systems, it is almost impossible to avoid writing unsafe code (unsafely typing or using address arithmetic). In C or C ++, doing unsafe things is very simple. Suppose I need to turn on the LED by writing 0xFFto the address 0x1234, then I can just do the following:

(C / C ++):

 * (uint8_t *) 0x1234 = 0xFF; 

This is extremely dangerous and only makes sense in very low-level system programming, so neither Go nor Haskell make it easy to do; These are not system programming languages.

The right approach: isolating unsafe code

Rust, which focuses on both security and system programming, provides a good way for a tool — blocks of unsafe code [orig. "Unsafe code blocks" - approx. translator], a good way to explicitly separate a dangerous code from a safe one Here is the same example with an entry 0xFFat an address 0x1234in the Rust language:

(Rust):

 unsafe{ * (0x1234 as * mut u8) = 0xFF; } 

If we tried to do this outside a block of unsafe code, the compiler would be loudly indignant. This allows us to do all the unhappy, but necessary dangerous operations inherent in programming for embedded systems, while at the same time preserving the security of the code.

Go approach: no

Go is not sharpened for such things and has no built-in support for them.

TL; DR


You can still say, “But why is Go not good? This is just a list of complaints; You can complain about any language at all! ” It's true; no perfect language. However, I hope my whining still slightly showed that:

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


All Articles