Good all the time of day!
So, I decided to continue the story about the wonderful programming language D.
My
last article was about multi-paradigm language, that it naturally and harmoniously supports the majority of modern popular programming styles.
This time I decided to highlight the other side of the language - less general and fundamental, but no less useful. Namely, the possibilities of metaprogramming and compile-time computations.
I begin, perhaps with all the usual things - generic programming (generics, templates). That is, from familiar to all of us in C ++ templates.
For a start, what it is at a simple level: generic programming (template functions and data types) is a way to provide the ability to reuse code. When a programmer writes code for some generalized type, and then substitutes concrete.
In language D, a heterogeneous approach is chosen. This means that the templates in the depths of the language implementation are nothing more than type-safe macros, and each specific type substituted into the template simply generates a separate implementation. Let's try to describe a simple, and, I admit, little meaningful template function:
')
T[] MapInc(T)(T[] arr) { auto res = new T[arr.length]; foreach(i, v; arr) res[i] = v + 1; return res; }
First of all, I note that the return result is also parameterized. Very convenient property.
Let's see how it works:
void main() { auto ar = [1,2,3,4]; auto ard = [1.0,2.0,3.0,4.0]; assert(MapInc(ar) == [2,3,4,5], "wrong!"); assert(MapInc(ard) == [2.0,3.0,4.0,5.0], "wrong!"); }
All tests passed as expected! Undoubtedly, success. But wait ... let's try this code:
auto ar = ["1","2","3","4"]; MapInc(ar);
Not compiled? Not surprising. Compilation error on line
res[i] = v + 1;
and as a result, on the line
MapInc(ar)
Now imagine that this function is not written by us, but buried deep in the library. Joyless right
However, D provides a very convenient way to resolve a misunderstanding. Since one line of code replaces a thousand words, just take a look:
T[] MapInc(T)(T[] arr) if(is(typeof(arr[0] + 1) == typeof(arr[0]))) // . { auto res = new T[arr.length]; foreach(i, v; arr) res[i] = v + 1; return res; }
Thus, we simply and elegantly indicated to the compiler, for which input data the function is intended, and if it finds the code that gave errors in two lines at once, and one more directly in the function, then it will check the condition and generate an error right on the spot. And, most importantly, it's completely free!
Of course, by free, I mean the fact that all such checks are carried out at the compilation stage, without taking away a single tact in runtime. This, by the way, means that we can write only those checks that can be calculated at the compilation stage.
Now back to the code:
if(is(typeof(arr[0] + 1) == typeof(arr[0])))
To you, it probably seems like sheer shamanism. How to calculate at the stage of compilation arr [0] and even + 1? The correct answer - and no way. The compiler does not calculate this value. In this case, typeof does not evaluate its own argument, it simply displays the type of the value that is passed to it, as an argument.
In this way
typeof(arr[0] + 1) == typeof(arr[0])
just means that:
a) We can add typeof (1) type values to typeof (arr [0]) type values and
b) After the addition, we again get the typeof type (arr [0]).
Wow, isn't it?
So, we have written a function that can add one to all values of the array, while it accurately reports if it is impossible to add one in principle. Seems not bad? However, the next is better.
To get to better, you have to sweat a little more and modify the example:
T[] MapInc(T)(T[] arr, T b) if(is(typeof(arr[0] + b) == typeof(arr[0]))) { auto res = new T[arr.length]; foreach(i, v; arr) res[i] = v + b; return res; }
Now we add not a unit, but an arbitrary number. Check our function:
void main() { auto ar = [1,2,3,4]; assert(MapInc(ar,3) == [4,5,6,7], "wrong!"); }
void main() { auto ar = [1,2,3,4]; assert(MapInc(ar,3) == [4,5,6,7], "wrong!"); }
Success! But wait, we treated her too gently, let's try to somewhat complicate the task:
void main() { auto ar = [1.0,2.0,3.0,4.0]; assert(MapInc(ar,1) == [2.0,3.0,4.0,5.0], "wrong!");
Oops! The compiler could not understand our delightful idea. What is it: another grumpy strict type system wishes us evil? But let's take a closer look: we are to blame! The definition clearly states that the array and the number are of the same type. Good. Let us try to modify the example to accept values of different types.
T[] MapInc(T,P)(T[] arr, P b) if(is(typeof(arr[0] + b) == T)) { auto res = new T[arr.length]; foreach(i, v; arr) res[i] = v + b; return res; }
Now the final success:
void main() { auto ar = [1.0,2.0,3.0,4.0]; assert(MapInc(ar,1) == [2.0,3.0,4.0,5.0], "wrong!");
I also note that the condition in the header of the function did not have to change - we want exactly the same as in the previous version.
Now it's time to clarify lovers of rigor. This is the option to write the function call:
MapInc(ar,1)
Just the result of automatic type inference. But how are things really:
In D, each function has two sets of arguments and is generally defined as
T f(c1,c2/*, others*/)(r1,r2/*, others*/)
The first set is the set of compile-time arguments, the second is the runtime.
The name speaks for itself: arguments from the first set are computed at compile time, while from the second set at run time. And that is not always.
Thus, all the arguments of the compilation time are again given to us “for free”, but again we cannot use the noncomputable values here during the compilation.
For calling polymorphic functions, the syntax is:
auto v = f!(c1,c2/*, others*/)(r1,r2/*, others*/)
In this case, if the compile time argument is only one, then you can omit the brackets:
auto v = f!c1(r1,r2/*, others*/)
Compile-time arguments can be not only types, but in general any expressions computable at the compilation stage. For example, "42".
But 42 will fit in the C ++ templates, right there everything is much more interesting: you can even use functions as such parameters! Consider an example:
P[] Map(alias f,T,P)(T[] arr) if(is(typeof(f(arr[0])) == P)) { auto res = new P[arr.length]; foreach(i, v; arr) res[i] = f(v); return res; } void main() { auto ard = [1.0,2.0,3.0,4.0]; auto ar = [1,2,3,4]; assert(Map!((double x) {return x+1;},double,double)(ard) == [2.0,3.0,4.0,5.0], "wrong!"); assert(Map!((int x) {return x+1.0;},int,double)(ar) == [2.0,3.0,4.0,5.0], "wrong!"); assert(Map!((int x) {return x+1;},int,int)(ar) == [2,3,4,5], "wrong!"); assert(Map!((double x) {return x+1.0;},int,double)(ar) == [2.0,3.0,4.0,5.0], "wrong!"); }
In this case, I had to help the compiler and manually determine the types for the template parameters. Not sure, but probably you can somehow define this function so that it is not needed.
I propose to arrange a contest for the most beautiful map implementation in the comments :)But on the other hand - look how great it turned out!
Let's compare this approach with the C ++ 11 STL approach using the example of the well-known sort algorithm.
auto arr = {1,2,3,4}; sort(arr.begin(), arr.end(), [&](int a, int b) {return a > b && a < 42;});
What's going on here? That's right, sorting the array, at the same time to determine if the elements need to be swapped, they need to be compared and our lambda function is called for this (thanks for at least now lambda, before you had to write a function). Called every time. And in this (and, probably, in many) case, the cost of calling a function is comparable to the time it takes to calculate the function itself, even if it does not exceed it. Unforgivable waste of performance.
At that time, as in D, we have seen that a function is a compile time argument, and, accordingly, it is calculated at compile time, and accordingly it is inline elementary. So, we can say, in some ways, D even surpasses C ++ in efficiency, as they say, by design.
The article has already been smashed, but I just started!
So, let's continue with the calculations during the compilation process.
We all remember, but unsharply and sin with expressions like
#ifdef P ... #else ... #endif
In D there is no preprocessor (someone will say: “and thank God”, someone will frown, but we will not arrange holivar, but continue to read).
However, there are constructions in the language that replace it. For example, the above construction replaces the expression static if. In general, we treated him very (believe me, well, sooo) unfairly: static if can be incomparably more.
Here you need a lyrical digression. D has the alias keyword. It works a lot like, but now we need it as a typedef. Here is an example:
alias int MyOwnPersonalInt;
So, static if. I will try to show in practice:
enum Arch {x86, x64}; static Arch arch = x86; static if(arch == x86) alias int integer; else alias long integer;
We have defined an integer type, the size of which depends on the architecture of the machine on which the code is compiled.
Now we can use it like any other type:
integer Inc(integer n) {return n+2;}
You can write static if literally anywhere: In global code, in functions, and even in class definitions!
Here you need a small addition, the expression "static else" no! Use the usual else, no collisions can occur, nesting is taken into account.
And finally, another interesting and useful feature. One of the principles of the ideology of a language is: to calculate everything that can be calculated at compile time.
Consider this code:
static int a = f();
The language assumes that the compiler checks the ability to calculate f () at compile time using the interpreter, and if this is not possible, an error is generated, otherwise the value is simply substituted into the code. For D, the common practice is to write functions that must be computed at compile time.
Once again: unlike C / C ++, static variables are initialized not during the first call of the initialization code, but during the compilation of the program and, accordingly, must be initialized expressions computable during compilation.
Well, that's probably for today and everything. An attentive reader, of course, noted that I did not mention the generalized data — classes, interfaces, and structures, but from the point of view of metaprogramming, the difference with generalized functions is small, so I leave it to self-study for those interested.
I hope I did my job well and this article will be able to interest people in the programming language D.
Those who could read this over - I hope you enjoyed it and thank you for your attention!
PS
In the comments to my previous article there was a lot of grumbling in the direction of D. And claims of the inefficiency of standard types, and complaints about the lack of tools, and even support for different architectures. Please keep grumbling with you - for the time being. I hope you will like the article and I will be able to continue to delight you with other articles about the D language and in due time I will definitely get to every problem that has been voiced, study them and present them to the public, and then I will be able to articulate competently about your doubts they are justified or not.