C ++ 20的悲嘆,未出世就被羣嘲“勸退”

爲了C++20,C++標準委員會曾舉辦歷史上規模最大的一次會議(180人蔘會),試圖通過會議確定哪些特性可以加入新版本,我們也已經看到媒體爆料的部分新特性,比如Concepts、Ranges、Modules、Coroutines等,但大部分開發人員並不認可此次調整,並將部分新特性歸結爲“語法糖”。

不少網友看到上述特性紛紛在社交平臺吐槽,表示不看好C++20版本的發佈:

不僅國內如此,國外的一位遊戲領域開發人員接連在社交平臺發表看法,聲明自己不看好C++20的新特性,並認爲新版本沒有解決最關鍵的問題,他通過使用畢達哥拉斯三元數組示例對C++20標準下的代碼和舊版本進行對比,明確闡述自己對於C++20的態度。

畢達哥拉斯三元數組,C ++ 20 Ranges風格

以下是C++20標準下代碼的完整示例:

// 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),  {
      return for_each(iota(1, z+1), = {
        return for_each(iota(x, z+1), = {
          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';
  }
}

以下代碼爲簡單的C函數打印第一個N Pythagorean Triples:

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時立即停止整個算法,應該怎麼辦呢?

畢達哥拉斯三元數組,簡單的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 simplest.cpp -o outsimplest,需要花費0.064秒,產生8480字節可執行文件,在2毫秒內運行並打印數字(使用的電腦是2018 MacBookPro,Core i9 2.9GHz,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版本的構建,優化的Release版本構建:clang simplest.cpp -o outsimplest -O2,編譯花費0.071秒,生成相同大小(8480b)的可執行文件,並在0ms內運行(在clock()的計時器精度下)。

接下來,對上述代碼進行改進,加入代碼調用並返回下一個三元數組,代碼如下:

// 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結構,每次調用next()都會跳到下一個有效三元組,調用者可隨意做任何事情,此處只調用一百次,每次打印三聯。

雖然實現的功能等同於三重嵌套for循環,但C++ 20標準下的代碼讓人感覺不是很清楚,無法立即讀懂程序邏輯。如果C ++有類似coroutine的概念,就可能實現三元組生成器,並且和原始的for循環嵌套一樣清晰:

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 ++20 Ranges會讓整段代碼更加清晰嗎?結果如下:

auto triples =
    for_each(iota(1),  {
        return for_each(iota(1, z+1), = {
            return for_each(iota(x, z+1), = {
                return yield_if(x*x + y*y == z*z,
                    make_tuple(x, y, z));
                });
            });
        });

多次return實在是讓人感覺很奇怪,這或許不應該成爲好語法的標準。

C++存在的問題有哪些?

如果談到C++的問題,至少有兩個:一是編譯時間;二是運行時性能。雖然C++ 20 Ranges還未正式發佈,但本文使用了它的近似版,即isrange-v3(由Eric Niebler編寫),並編譯了規範的“Pythagorean Triples with C ++ Ranges”示例:

// 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),  {
        return view::for_each(view::ints(1, z + 1), = {
            return view::for_each(view::ints(x, z + 1), = {
                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;
}

該代碼使用0.4.0之後的版本,並用clang ranges.cpp -I. -std=c++17 -lc++ -o outranges編譯,整個過程花費2.92秒,可執行文件爲219千字節,運行在300毫秒之內。

這是一個非優化的構建,優化構建版本(clang ranges.cpp -I. -std=c++17 -lc++ -o outranges -O2)在3.02秒內編譯,可執行文件爲13976字節,並在1ms內運行。因此運行時性能很好,可執行文件稍大,編譯時問題仍然存在。

C++20比簡單版本的代碼編譯時間長近3秒

編譯時是C ++的一個大問題,這個非常小的例子編譯時間比簡單版的C ++長2.85秒。在3秒內,現代CPU可以進行大量操作,比如,在Debug構建中編譯一個包含22萬行代碼的數據庫引擎(SQLite)只需要0.9秒。所以,編譯一個簡單的5行示例代碼比運行完整的數據庫引擎慢三倍?

在開發過程中,C ++編譯時間一直是大小代碼庫的痛苦根源。他認爲,C ++新版本應該把解決編譯時問題排在第一位。但是,整個C++社區好像並不知道該問題,每個版本都將更多內容放入頭文件,甚至放入必須存在於頭文件的模板化代碼中。

range-v3是1.8兆字節的源代碼,全部在頭文件中,因此,雖然使用C++ 20輸出100個三元數組的代碼示例只有30行,但加上頭文件後,編譯器最終會編譯102,000行代碼。在所有預處理之後,簡單版本的C ++示例只有720行代碼。

調試構建性能差

Ranges示例的運行時性能慢了150倍,這對於要解決實際問題的代碼庫而言,兩個數量級的速度可能意味着不會對任何實際數據集起作用。該開發者在遊戲行業工作,這意味着引擎或工具的Debug版本不適用於任何真實的遊戲級別模擬(性能無法接近所需的交互級別)。

通過避免STL位(提交),可以讓最終運行時快10倍,也可以讓編譯時間更快且調試更容易,因爲微軟的STL實現特別喜歡深度嵌套的函數調用。這並不是說STL必然不好,有可能編寫STL實現在非優化版本中不會變慢10倍(如EASTL或libc ++那樣),但由於微軟的STL過度依賴深度嵌套,因此會變慢。

作爲語言的使用者,大部分人不關心它是否正確發展!即便知道STL在Debug中太慢,寧願花時間修復或者研究替代方案(例如不使用STL,重新實現需要的位,或者完全停止使用C ++)也不會花時間整理論文上報C++委員會,這太浪費時間。

其他語言如何?

這裏簡要介紹C#中“畢達哥拉斯三元數組”實現,以下是完整C#源代碼:

using System;using System.Diagnostics;using System.Linq;
class Program
{
    public static void Main()
    {
        var timer = Stopwatch.StartNew();
        var triples =
            from z in Enumerable.Range(1, int.MaxValue)
            from x in Enumerable.Range(1, z)
            from y in Enumerable.Range(x, z)
            where x*x+y*y==z*z
            select (x:x, y:y, z:z);
        foreach (var t in triples.Take(100))
        {
            Console.WriteLine($"({t.x},{t.y},{t.z})");
        }
        timer.Stop();
        Console.WriteLine($"{timer.ElapsedMilliseconds}ms");
    }
}

就個人而言,C#可讀性較高:

var triples =
    from z in Enumerable.Range(1, int.MaxValue)
    from x in Enumerable.Range(1, z)
    from y in Enumerable.Range(x, z)
    where x*x+y*y==z*z
    select (x:x, y:y, z:z);

用C ++:

auto triples = view::for_each(view::ints(1),  {
    return view::for_each(view::ints(1, z + 1), = {
        return view::for_each(view::ints(x, z + 1), = {
            return yield_if(x * x + y * y == z * z,
                std::make_tuple(x, y, z));
        });
    });
});

C#LINQ的另一種“數據庫較少”的形式:

var triples = Enumerable.Range(1, int.MaxValue)
    .SelectMany(z => Enumerable.Range(1, z), (z, x) => new {z, x})
    .SelectMany(t => Enumerable.Range(t.x, t.z), (t, y) => new {t, y})
    .Where(t => t.t.x * t.t.x + t.y * t.y == t.t.z * t.t.z)
    .Select(t => (x: t.t.x, y: t.y, z: t.t.z));

在Mac上編譯這段代碼,需要使用Mono編譯器(本身是用C#編寫的),版本5.16。mcs Linq.cs需要0.20秒。相比之下,編譯等效的簡單C#版本需要0.17秒。LINQ樣式爲編譯器創建了額外的0.03秒。但是,C ++卻創造了額外的3秒。

一般來說,我們會試圖避免大部分STL,使用自己的容器,哈希表使用開放尋址代替…甚至不需要標準庫的大部分功能。但是,難免需要時間說服每一位新員工(尤其是應屆生),因爲C++20被稱爲現代C ++,很多新員工認爲“新一定就是好”,其實並不是這樣。

爲什麼C ++會這樣?

該開發者表示不太清楚C++爲什麼會發展到現在這個地步。 但他個人認爲,C++社區需要學會“保持接近100%向後兼容的同時發展一種語言”。 在某種程度上,現在的C++生態系統太專注於炫耀或證明其價值的複雜性,卻並不易用。

在他的印象中,大多數遊戲開發人員還停留在C++ 11、14或者17版本,C++20基本忽略了一個問題,無論什麼被添加到標準庫,編譯時間長和調試構建性能差的問題沒有解決都是無用的。

對於遊戲產業而言,大部分傳統技術是用C或C ++構建的,在很長一段時間內,沒有出現可行的替代品(好在目前至少可以用Rust作爲可能的競爭者),對C和C ++的依賴程度很高,需要社區的一些幫助和迴應。

參考鏈接:http://aras-p.info/blog/2018/12/28/Modern-C-Lamentations/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章