C++ 20 還未釋出,就已涼涼?
一個月前,C++ 標準委會於美國 San Diego 舉辦了有史以來規模最大的一次會議(180 人蔘會),特地討論了 C++ 新標準即 C++ 20 將確認新增以及有可能新增的特性。
而如今距離 C++ 20 標準的正式釋出不足一個月,C++ 20 卻慘遭國內外開發者嫌棄。對此,更有一位來自國外的遊戲開發者通過使用畢達哥拉斯三元陣列示例發萬字長文批判新版本的到來並沒有解決最關鍵的技術問題。這到底是怎麼一回事?接下來,我們將一窺究竟。
作者 | Aras Pranckevičius
譯者 | 蘇本如
責編 | 屠敏
出品 | CSDN(ID:CSDNNews)
以下為譯文:
首先宣告,本文較長,但我想表達的主要觀點是:
C++編譯時間很重要;
非優化的build效能很重要;
認知負荷很重要。我在這裡不詳細闡述,但是如果一種程式語言或一個庫讓我覺得自己很愚蠢,那麼我就不太可能使用它或喜歡它。C++很多地方就讓我有這樣的感覺。
Facebook工程師、Microsoft Visual C++開發者Eric Niebler在他的博文”Standard Range”(http://ericniebler.com/2018/12/05/standard-ranges/)介紹了C++ 20的Range特性在最近的Twitter遊戲開發的各種應用,很多地方都表達了對於Modern C++現狀的“不喜歡”。
我也在這裡表達了類似的看法:
使用C++ 20 Range和其他特性來計算畢達哥拉斯三元組的例子聽起來很可怕。是的,我明白Range在這裡是有用的,結果也是對的……。但是,這仍然是一個可怕的例子!沒有人想要這樣的程式碼?!
感覺有點失控了。這裡我要為引用Eric的博文向他道歉,我的悲觀看法大部分是基於最近的C++的現狀,Twitter上的“Bunch of angry gamedev”已經採用了Boost庫,Geometry rationale也同樣這樣做了,同樣的事情發生在C++生態系統中也有十幾次。
但你知道,Twitter上無法表達太多細節,所以我在這裡展開一下!
使用C++20 Ranges來計算畢達哥拉斯三元陣列
這裡是Eric的博文裡的完整例子:
// A sample standard C++20 program that prints
// the first N Pythagorean triples.
#include <iostream>
#include <optional>
#include <ranges> // New header!
using namespace std;
// maybe_view defines a view over zero or one
// objects.
template<Semiregular T>
struct maybe_view : view_interface<maybe_view<T>> {
maybe_view() = default;
maybe_view(T t) : data_(std::move(t)) {
}
T const *begin() const noexcept {
return data_ ? &*data_ : nullptr;
}
T const *end() const noexcept {
return data_ ? &*data_ + 1 : nullptr;
}
private:
optional<T> data_{};
};
// "for_each" creates a new view by applying a
// transformation to each element in an input
// range, and flattening the resulting range of
// ranges.
// (This uses one syntax for constrained lambdas
// in C++20.)
inline constexpr auto for_each =
[]<Range R,
Iterator I = iterator_t<R>,
IndirectUnaryInvocable<I> Fun>(R&& r, Fun fun)
requires Range<indirect_result_t<Fun, I>> {
return std::forward<R>(r)
| view::transform(std::move(fun))
| view::join;
};
// "yield_if" takes a bool and a value and
// returns a view of zero or one elements.
inline constexpr auto yield_if =
[]<Semiregular T>(bool b, T x) {
return b ? maybe_view{std::move(x)}
: maybe_view<T>{};
};
int main() {
// Define an infinite range of all the
// Pythagorean triples:
using view::iota;
auto triples =
for_each(iota(1), [](int z) {
return for_each(iota(1, z+1), [=](int x) {
return for_each(iota(x, z+1), [=](int y) {
return yield_if(x*x + y*y == z*z,
make_tuple(x, y, z));
});
});
});
// Display the first 10 triples
for(auto triple : triples | view::take(10)) {
cout << '('
<< get<0>(triple) << ','
<< get<1>(triple) << ','
<< get<2>(triple) << ')' << '\n';
}
}
Eric的這篇博文可以追溯到他幾年前發的另一篇博文(http://ericniebler.com/2014/04/27/range-comprehensions/),這是對Bartosz Milewski寫的這篇博文“Getting Lazy with C++”(https://bartoszmilewski.com/2014/04/21/getting-lazy-with-c/)的迴應,Eric在那篇博文給出了用一個簡單C函式來打印出前N個畢達哥拉斯三元陣列的例子:
void printNTriples(int n)
{
int i = 0;
for (int z = 1; ; ++z)
for (int x = 1; x <= z; ++x)
for (int y = x; y <= z; ++y)
if (x*x + y*y == z*z) {
printf("%d, %d, %d\n", x, y, z);
if (++i == n)
return;
}
}
從這個示例程式碼可以看出一些問題:
這段程式碼看起來沒有問題,如果您不需要修改它或重用它的話。但是如果你想對它做更多的事問題就出來了,比如你不僅僅想打印出這些三元陣列,而且想基於這些三元陣列畫一些三角形呢?或者如果你想在其中一個數字達到100時立即停止計算呢?
列表解析(list Comprehension)和延後計算(lazy evaluation)似乎可以作為解決這些問題的方法。事實上,它根本解決不了這些問題,因為C++語言不具備像Haskell或其他語言那樣的內建功能。C++ 20在這方面會有更多內建的東西,如Eric的博文指出的內容。這部分容我稍後再談。
使用Simple C++來計算畢達哥拉斯三元陣列
所以,讓我們回到簡單的(正如Bartosz所說的“只要你不需要修改或重用”)C/C++方式解決這個問題。下面是一個完整的程式用來列印前100個畢達哥拉斯三元陣列:
// simplest.cpp
#include <time.h>
#include <stdio.h>
int main()
{
clock_t t0 = clock();
int i = 0;
for (int z = 1; ; ++z)
for (int x = 1; x <= z; ++x)
for (int y = x; y <= z; ++y)
if (x*x + y*y == z*z) {
printf("(%i,%i,%i)\n", x, y, z);
if (++i == 100)
goto done;
}
done:
clock_t t1 = clock();
printf("%ims\n", (int)(t1-t0)*1000/CLOCKS_PER_SEC);
return 0;
}
我們可以編譯它:clang simpest.cpp-o outsimpest。編譯需要0.064秒,生成8480位元組的可執行檔案,該檔案在2毫秒內執行並列印數字(使用的機器為2018產的 MacBookPro;Core i9 2.9GHz CPU;xcode 10 clang):
(3,4,5)
(6,8,10)
(5,12,13)
(9,12,15)
(8,15,17)
(12,16,20)
(7,24,25)
(15,20,25)
(10,24,26)
...
(65,156,169)
(119,120,169)
(26,168,170)
但是等等!這是一個預設的非優化(“debug”)build;讓我們用這個命令來做一次優化(“release”)build:clang simplest.cpp -o outsimplest -O2。它需要0.071進行編譯,生成相同大小(8480b)的可執行檔案,並在0毫秒內執行(在clock()的計時器精度內)。
正如Bartosz指出的那樣,這裡的演算法不是“可重用的”,因為它將處理過程和輸出結果混雜在一起了。這是不是一個問題不在本文的討論範圍之內(我個人認為,“可重用性”或“不惜一切代價避免重複”都被高估了)。讓我們假設這是一個問題,事實上,我們想要的是一個只返回前n個三元陣列的“東西”,而不需要對它們做任何額外操作。
我可能會寫的是一段最簡單的程式碼 - 呼叫它,返回下一個三元陣列 - 看起來像下面這樣:
// simple-reusable.cpp
#include <time.h>
#include <stdio.h>
struct pytriples
{
pytriples() : x(1), y(1), z(1) {}
void next()
{
do
{
if (y <= z)
++y;
else
{
if (x <= z)
++x;
else
{
x = 1;
++z;
}
y = x;
}
} while (x*x + y*y != z*z);
}
int x, y, z;
};
int main()
{
clock_t t0 = clock();
pytriples py;
for (int c = 0; c < 100; ++c)
{
py.next();
printf("(%i,%i,%i)\n", py.x, py.y, py.z);
}
clock_t t1 = clock();
printf("%ims\n", (int)(t1-t0)*1000/CLOCKS_PER_SEC);
return 0;
}
上面這段程式碼在幾乎相同的時間編譯和執行;Debug版本的可執行檔案將變大168位元組;Release版本的可執行檔案大小不變。
這裡我定義了一個名為pytriples的結構體(struct pytriples),通過對它的next()函式的每次呼叫都會得到下一個有效的三元陣列;呼叫方可以對返回結果做任意處理。在這裡,我只是呼叫了100次next()函式,然後將每次返回的三元陣列打印出來。
然而,上段程式碼在功能上實現了原始示例中的三重巢狀for迴圈所做的工作。但事實上,它變得不那麼清晰了,至少對我來說是這樣,雖然我很清楚在一些分支和對整數的簡單操作上它是如何做到的,但是搞不清在更高層次上它是怎麼做的。
如果C++有一個類似“coroutine”的概念,那麼它有可能將三元陣列生成器的程式碼變得與原始巢狀for迴圈一樣清晰,這樣實現沒有任何“問題”,就像Jason Meisel在博文“Ranges, Code Quality, and the Future of C++”指出的那樣。程式碼看起來是這樣的(它只是假設的寫法,因為coroutines不是任何C++ 標準的一部分):
generator<std::tuple<int,int,int>> pytriples()
{
for (int z = 1; ; ++z)
for (int x = 1; x <= z; ++x)
for (int y = x; y <= z; ++y)
if (x*x + y*y == z*z)
co_yield std::make_tuple(x, y, z);
}
回到C++ Ranges
用C++ 20的Ranges來實現三元陣列的寫法會更清楚嗎?讓我們再看看Eric的博文,這是程式碼的主要部分:
auto triples =
for_each(iota(1), [](int z) {
return for_each(iota(1, z+1), [=](int x) {
return for_each(iota(x, z+1), [=](int y) {
return yield_if(x*x + y*y == z*z,
make_tuple(x, y, z));
});
});
});
兩種方式都有爭議。我認為上面的“coroutines”方法更清晰。使用C++的Lambda表示式,或者使用看起來更聰明些的標準C++方式,都有些累贅。如果讀者習慣了指令式程式設計風格,那麼多個Return返回就顯得不常見了,但可能有人會習慣它。
也許你可以眯起眼睛說這是一個可以接受的好的寫法。
但是,我不相信像我們一樣的沒有C++博士學位的凡人能夠寫出下面的實用工具供上面程式碼使用:
template<Semiregular T>
struct maybe_view : view_interface<maybe_view<T>> {
maybe_view() = default;
maybe_view(T t) : data_(std::move(t)) {
}
T const *begin() const noexcept {
return data_ ? &*data_ : nullptr;
}
T const *end() const noexcept {
return data_ ? &*data_ + 1 : nullptr;
}
private:
optional<T> data_{};
};
inline constexpr auto for_each =
[]<Range R,
Iterator I = iterator_t<R>,
IndirectUnaryInvocable<I> Fun>(R&& r, Fun fun)
requires Range<indirect_result_t<Fun, I>> {
return std::forward<R>(r)
| view::transform(std::move(fun))
| view::join;
};
inline constexpr auto yield_if =
[]<Semiregular T>(bool b, T x) {
return b ? maybe_view{std::move(x)}
: maybe_view<T>{};
};
也許上面程式碼對某些人來說就像母語那樣易讀,但對我來言,感覺就像有人告訴我“Perl的可讀性太好,而Brainfuck的可讀性太不好了,讓我們平衡一下吧”。但是我還是覺得很難讀,儘管我用C++程式設計已經20年了,可能是我太蠢吧。
是的,這裡的maybe_view, for_each, yield_if都是“可重用元件”,這些元件可以移到一個庫中。 接下來我就要討論這個問題。
C++的“一切都是Library”帶來的問題
問題至少有兩個:1)編譯時間;2)非優化的執行時效能。
我還是以同樣的畢達哥拉斯三元陣列的例子為例,這些問題對於C++的許多其他特性也存在,因為它們也是作為Library的一部分來實現的。
實際上C++ 20還沒有釋出,我用了當前最接近C++20 Range的Range-v3(Eric Niebler自己寫的),來實現標準的“畢達哥拉斯三元陣列”,並做了快速編譯測試。程式碼如下:
// ranges.cpp
#include <time.h>
#include <stdio.h>
#include <range/v3/all.hpp>
using namespace ranges;
int main()
{
clock_t t0 = clock();
auto triples = view::for_each(view::ints(1), [](int z) {
return view::for_each(view::ints(1, z + 1), [=](int x) {
return view::for_each(view::ints(x, z + 1), [=](int y) {
return yield_if(x * x + y * y == z * z,
std::make_tuple(x, y, z));
});
});
});
RANGES_FOR(auto triple, triples | view::take(100))
{
printf("(%i,%i,%i)\n", std::get<0>(triple), std::get<1>(triple), std::get<2>(triple));
}
clock_t t1 = clock();
printf("%ims\n", (int)(t1-t0)*1000/CLOCKS_PER_SEC);
return 0;
}
使用命令:clang ranges.cpp -I. -std=c++17 -lc++ -o outranges 來編譯