508.6
Rating
JUG Ru Group
Конференции для программистов и сочувствующих. 18+
31 January 2019

«Современный» C++: сеанс плача с причитаниями

JUG Ru Group corporate blogHigh performanceC++Game developmentAlgorithms
Translation
Original author: Aras Pranckevičius

Здесь будет длиннющая стена текста, с типа случайными мыслями. Основные идеи:


  1. В C++ очень важно время компиляции,
  2. Производительность сборки без оптимизаций тоже важна,
  3. Когнитивная нагрузка ещё важней. Вот по этому пункту особо распространяться не буду, но если язык программирования заставляет меня чувствовать себя тупым, вряд ли я его буду использовать или тем более — любить. C++ делает это со мной постоянно.

Блогпост «Standard Ranges» Эрика Ниблера, посвященный ренжам в C++20, недавно облетел всю твиттерную вселенную, сопровождаясь кучей не очень лестных комментариев (это ещё мягко сказано!) о состоянии современного C++.



Даже я внёс свою лепту (ссылка):


Этот пример пифагоровых троек на ренжах C++20, по моему, выглядит чудовищно. И да, я понимаю, что ренжи могут быть полезны, проекции могут быть полезны и так далее. Тем не менее, пример жуткий. Зачем кому-то может понадобиться такое?

Давайте подробно разберём всё это под катом.



Всё это немножко вышло из-под контроля (даже спустя неделю, в это дерево тредов продолжали прилетать комментарии!).


Теперь, надо извиниться перед Эриком за то, что я начал с его статьи; мой плач Ярославны будет, в основном, об «общем состоянии C++». «Горстка озлобленных чуваков из геймдева» год назад наезжала на объяснение сути Boost.Geometry примерно тем же способом, и то же происходило по поводу десятков остальных аспектов экосистемы C++.


Но знаете, Твиттер — это не самое лучшее место для деликатных разговоров, и т.д и т.п. Придётся развернуть мысль прямо здесь и сейчас!


Пифагоровы тройки в стиле ренжей C++20


Держите полный текст примера из поста Эрика:


// Пример программы на стандартном C++20.
// Она печатает первые N пифагоровых троек.
#include <iostream>
#include <optional>
#include <ranges>   // Новый заголовочный файл!

using namespace std;

// maybe_view создаёт вьюху поверх 0 или 1 объекта
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" создает новую вьюху, применяя
// трансформацию к каждому элементу из изначального ренжа,
// и в конце полученный ренж ренжей делает плоским.
// (Тут используется синтаксис constrained lambdas из 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" берёт bool и значение, 
// возвращая вьюху на 0 или 1 элемент.
inline constexpr auto yield_if =
  []<Semiregular T>(bool b, T x) {
    return b ? maybe_view{std::move(x)}
             : maybe_view<T>{};
  };

int main() {
  // Определяем бесконечный ренж пифагоровых троек:
  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));
        });
      });
    });

    // Отображаем первые 10 троек
    for(auto triple : triples | view::take(10)) {
      cout << '('
           << get<0>(triple) << ','
           << get<1>(triple) << ','
           << get<2>(triple) << ')' << '\n';
  }
}

Пост Эрика появился из его же более раннего поста, написанного пару лет назад, который в свою очередь являлся ответом на статью Бартоша Милевского «Getting Lazy with 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;
                }
}

Там же были перечислены проблемы с этим кодом:


Всё отлично, пока вы не хотите изменять или переиспользовать этот код. Но что если, например, вместо распечатывания на экране вы захотите рисовать тройки как треугольники? Или вдруг вы захотели остановиться сразу же, как одно из чисел достигло сотни?

После чего ленивые вычисления со сборкой списков (list comprehensions) представляются как главный способ решать этим проблемы. Конечно, это действительно какой-то способ решить данные проблемы, ведь в языке C++ для этой задачи недостаточно встроенной функциональности, которая есть в каком-нибудь Haskell и других языках. C++20 получит больше для этого встроенных ништяков, на что и намекает пост Эрика. Но до этого мы ещё доберёмся.


Пифагоровы тройки в стиле простого C++


Так, давай вернёмся к стилю решения задачи, основанному на простом C/C++ («простом» — в смысле, «подходит, пока не нужно модифицировать или переиспользовать», по версии Бартоша). Держите законченную программу, которая распечатывает первую сотню троек:


// 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 секнду на компиляцию и на выходе получится экзешник того же размера (8480 байт), который работает за 0 миллисекунд (то есть, ниже чувствительности таймера clock()).


Как правильно заметил Бартош, алгоритм здесь нельзя переиспользовать, ведь он смешан с манипуляциями результатом вычислений. Вопрос «действительно ли это является проблемой» выходит за рамки этой статьи (лично я считаю, что «переиспользуемость» и задача «избежать дублирования любой ценой» слишком переоценены). Давайте предположим, что это проблема, и нам действительно нужно что-то, что вернёт первые 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;
}

Оно собирается и работает примерно за то же самое время. Отладочный экзешник вырастает на 168 байт, релизный остаётся того же размера.


Я сделал структуру pytriples, для которой каждый следующий вызов next() переходит к следующей валидной тройке; вызывающий код может делать с этим результатом всё, что душе угодно. Поэтому я просто зову его сто раз, и каждый раз распечатываю результат на экран.


Несмотря на то, что реализация является функционально эквивалентной тому, что делал цикл из трёх вложенных for-ов в изначальном примере, в реальности он стал гораздо менее очевидным, по крайней мере, для меня. Совершенно ясно, как он делает то, что он делает (несколько ветвлений и простые операции над целыми числами), но далеко не сразу понятно что именно он делает на высоком уровне.


Если бы в C++ было чего-нибудь вроде концепции корутин, стало бы возможно реализовать генератор троек, такой же лаконичный, как вложенные циклы в изначальном примере, но при этом не имеющий ни одну из перечисленных «проблем» (Джейсон Мейзель именно об этом говорит в статье «Ranges, Code Quality, and the Future of C++»); это могло быть нечто вроде (это предварительный синтаксис, потому что в стандарте 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++


Может ли стиль записи в виде ренжей C++20 более ясно справиться с этой задачей? Давайте взглянем на пост Эрика, на основную часть кода:


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));
                });
            });
        });

Каждый решает за себя. По мне так, подход с корутинами, описанный выше, куда как более читабельный. Тот способ, которым в C++ создаются лямбды, и то, как в стандарте 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 — все они являются «переиспользуемыми компонентами», которые можно перенести в библиотеку; эта тема, про которую я расскажу… да прямо сейчас.


Проблемы с подходом «Всё Является Библиотекой»


Существует как минимум два понимания производительности:


  1. Во время компиляции
  2. Во время выполнения неоптимизированной сборки

Продолжим иллюстрировать это на примере примера с пифагоровыми тройками, но на самом деле, эти проблемы справедливы для множества других фичей C++, реализованных как часть библиотек, а не как часть синтаксиса.


Финальная версия C++20 ещё не вышла, поэтому для быстрой проверки я взял текущее лучшее приближение ренжей, коим является range-v3 (написанное самим Эриком Ниблером), и собрал относительно него канонический пример с пифагоровыми тройками.


// 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;
}

Я использовал версию после 0.4.0 (9232b449e44 за 22 декабря 2018 года), и собрал с помощью команды 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 байтов, и выполняется за 1 миллисекунду. Скорость выполнения в рантайме хороша, размер экзешника чуть увеличился, а вот время компиляции всё так же осталось проблемой.


Углубимся в подробности.


Время компиляции — огромная проблема для C++


Время компиляции этого реально наипростейшего примера заняло на 2.85 секунды дольше, чем версия с «простым C++».


Если вы вдруг подумали, что «меньше 3 секунд» — слишком маленькое время, то совершенно нет. За три секунды современный CPU может произвести несметное число операций. Например, за какое время clang сможет скомпилировать настоящий полноценный движок базы данных (SQLite) в отладочном режиме, включая все 220 тысяч строчек кода? За 0.9 секунд на моём ноутбуке. В какой такой вселенной стало нормальным, чтобы тривиальный пример на 5 строчек компилировался в три раза дольше целого движка баз данных?


Время компиляции С++ было источником боли на всех нетривиальных по размеру кодовых базах, где я работал. Не верите мне? Хорошо, попробуйте собрать какую-нибудь из широкоизвестных кодовых баз (Chromium, Clang/LLVM, UE4, и так далее отлично подойдут для примера). Среди множества вещей, которые действительно хочется иметь в C++, вопрос времени компиляции, наверное, на самом первом месте списка, и был там всегда. Тем не менее, складывается ощущение, что сообщество C++ в массе своей притворяется, что это совсем даже и не проблема, и в каждой следующей версии языка они перекладывают в заголовочные файлы ещё больше разных вещей, ещё больше вещей появляется в шаблонном коде, который обязан быть в заголовочных файлах.


В большинстве своём это связано с доисторической концепцией «просто скопипастим всё содержимое файла» модели #include, унаследованной из Си. Но в Си есть тенденция хранить в заголовках только объявления структур и прототипы функций, в C++ же обычно нужно свалить туда все шаблонные классы/функции.


range-v3 представляет из себя кусок кода размером 1.8 мегабайтов, и всё это в заголовочных файлах! Несмотря на то, что пример с сотней пифагоровых троек занимает 30 строчек, после обработки заголовков компилятору придётся скомпилировать 102 тысячи строк. В «простом C++» после всех преобразований получается 720 строк.


Но ведь именно для этого есть предкомпилированные заголовки и/или модули! — так и слышу, что вы это сейчас сказали. Справедливо. Давайте положим заголовки библиотеки ренжей в precompiled header (pch.h с текстом: #include <range/v3/all.hpp>, заинклудим получившийся pch.h, создадим PCH: clang -x c++-header pch.h -I. -std=c++17 -o pch.h.pch, скомпилируем с помощью pch: clang ranges.cpp -I. -std=c++17 -lc++ -o outranges -include-pch pch.h.pch). Время компиляции станет 2.24 секунды. То есть, PCH может сэкономить нам около 0.7 секунды времени компиляции. С оставшимися 2.1 секундами они никак не помогут, и это куда дольше, чем подход с простым C++ :(


Производительность сборки без оптимизаций — важна


В рантайме пример с ренжами оказался в 150 раз медленней. Возможно, замедление в 2 или 3 раза ещё можно считать приемлемым. Всё, что в 10 раз медленней можно отнести в категорию непригодного к использованию. Больше, чем в сто раз медленней? Серьёзно?


На реальных кодовых базах, решающих реальные проблемы, разница в два порядка означает, что программа просто не сможет обработать настоящий объем данных. Я работаю в индустрии видеогейминга; по чисто практическим причинам это означает, что отладочные сборки игрового движка или тулинга не смогут обрабатывать настоящие игровые уровни (производительность даже не приблизится к необходимому уровню интерактивности). Возможно, существует такая индустрия, в которой можно запустить программу на наборе данных, подождать результата, и если это займет от 10 до 100 раз больше времени в отладочном режиме, это будет «досадно». Неприятно, раздражающе тормозно. Но если делается нечто, обязанное быть интерактивным, «досадно» превращается в «неприменимо». Вы буквально не сможете играть в игру, если она рендерит изображение со скоростью всего 2 кадра в секунду.


Да, сборка с оптимизациями (-O2 в clang) работает со скоростью «простого C++»… ну да, ну да, «zero cost abstractions», где-то слышали. Бесплатные абстракции до тех пор, пока вам неинтересно время компиляции и возможно использовать оптимизирующий компилятор.


Но отладка оптимизированного кода — это сложно! Конечно, это возможно, и даже является очень полезным навыком. Подобно тому, как езда на одноколёсном велосипеде тоже возможна и учит наиважнейшему навыку балансирования. Кое-кто умеет получать от этого удовольствие, и даже вполне неплох в данном занятии. Но большинство людей никогда не выберут моноцикл в качестве основного средства передвижения, так же как большинство людей не отлаживают оптимизированный код, если есть хоть малейшая возможность этого избежать.


Arseny Kapoulkine делал крутой стрим «Optimizing OBJ loader» на YouTube, там он упёрся в проблему тормознутости отладочной сборки, и сделал её в 10 раз быстрее, выбросив некоторые куски STL (коммит). Побочными эффектами стало ускорение компиляции (исходник) и упрощение отладки, поскольку реализация STL от Microsoft адски помешана на вложенных вызовах функций.


Это не к тому, что «STL — плохо»; возможно написать такую реализацию STL, которая не будет тормозить десятикратно в неоптимизированной сборке (EASTL и libc++ так умеют), но по какой-то причине Microsoft STL невероятно сильно тормозит потому, что они излишне сильно заложились на принцип «инлайнинг всё починит».


Как пользователю языка, мне всё равно, чья это проблема! Всё что мне известно изначально — «STL тормозит в отладочном режиме», и я бы предпочёл, чтобы кто-то это исправил уже. Ну или мне придётся искать альтернативы (например, не использовать STL, самостоятельно написать нужные лично мне вещи, или вообще отказаться от 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), [](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));
        });
    });
});

Мне ясно видно, что здесь чище написано. А вам? Если честно, то альтернатива на 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));

Сколько собирается этот код на C#? Я использую Mac, поэтому запустив на компиляторе Mono (который тоже написан на C#) версии 5.16 команду mcs Linq.cs получилось скомпилировать второй пример за 0.20 секунд. Эквивалентный пример на «простом C#» уложился в 0.17 секунд.


То есть, ленивые вычисления в стиле LINQ добавляют 0.03 секунды работы компилятора. Сравните с дополнительными 3 секундами для C++ — это в 100 раз больше!


Но ведь нельзя просто проигнорировать то, что не нравится?


Да, в какой-то степени.


Например, мы здесь в Unity любим шутить, что «за добавление в проект Boost можно оказаться уволенным по статье». Похоже, всё же не увольняют, потому что в прошлом году я обнаружил, что кто-то добавил Boost.Asio, всё стало дико медленно собираться, и мне пришлось разбираться с тем, что простое добавление asio.h инклудит за собой весь <windows.h>, со всеми кошмарными макросами внутри.


По большей части мы стараемся не использовать и большую часть STL. У нас есть собственные контейнеры, созданные по той же причине, что описаны во введении к EASTL — более однообразный способ доступа, работающий между различными платформами/компиляторами, более хорошая производительность в сборках без оптимизаций, лучшая интеграция с нашими собственными аллокаторами памяти и трекингом аллокаций. Есть и кое-какие другие контейнеры, чисто по причинам производительности (unordered_map в STL даже по идее не может быть быстрой, поскольку стандарт требует использования separate chaining; наша же хэш-таблица использует вместо этого открытую адресацию). Большая часть стандартной библиотеки нам и не нужна совсем.


Тем не менее.


Требуется время, чтобы убедить каждого нового сотрудника (особенно джуниоров, только что вышедших из университета) что нет, «современный» C++ не означает автоматически, что он лучше старого (он может быть лучше! а может и не быть). Или например, что «код на Си» не обязательно значит, что его сложно понимать и он весь завален багами (может быть, так и есть! а может и нет).


Всего пару недель назад я жаловался всем и каждому, как я пытаюсь понять один конкретный кусок (нашего собственного) кода, и не могу, потому что этот код «слишком сложный» для меня. Другой (джуниор) подсел рядом и спросил, почему я выгляжу так, как будто готов ‎(ノಥ益ಥ)ノ ┻━┻, я сказал «ну, потому что пытаюсь понять этот код, но для меня он слишком сложный». Его мгновенная реакция была вроде: «о, это какой-то старый код в стиле Си?». И я такой: «нет, в точности до наоборот!». (Код, о котором идёт речь, был чем-то вроде шаблонного метапрограммирования). Он не работал ни над большими кодовыми базами, ни над C или C++, но нечто уже убедило его, что нечитаемым должен быть именно код на Си. Я виню университет; обычно студентам сразу же втирают, что «Си — это плохо», и потом никогда не объясняют — почему; это оставляет неизгладимый отпечаток на неокрепшей психике будущих программистов.


Поэтому да, я определённо склонен игнорировать те части C++, которые мне не нравятся. Но обучать всех коллег вокруг весьма утомительно, поскольку слишком многие находятся под влиянием идей вроде «современное — значит хорошее», или «стандартная библиотека должна быть лучше, чем что угодно, что мы сможем написать сами».


Почему такое происходит с C++?


Понятия не имею. У них есть очень сложная задача, «как продолжать эволюцию языка, сохраняя почти стопроцентную обратную совместимость с решениями, сделанными на протяжении многих десятков лет». Наложите этот факт на то, что C++ пытается служить сразу нескольким хозяевам, учитывать множество способов использования и уровней опыта, и у вас появилась огромная проблема!


Но до какой-то степени, есть ощущение, что большая часть комитета C++ и экосистемы сфокусирована на «сложности» в смысле доказательства собственной полезности.


В интернетах ходит шутка о стадиях развития программиста на C/C++. Я помню, как был на средней стадии где-то лет 16 назад. Был очень поражён Boost, в том смысле что: «вау, такие шутки можно делать, это так круто!». Не задаваясь вопросом, зачем это вообще делать.


Точно так же, ну например, автомобили Formula 1 или гитары с тремя грифами. Поразительно? Конечно. Чудо инженерной мысли? Безусловно. Требует огромного скилла, чтобы управляться с ними? Да! Не является правильным инструментом для 99% ситуаций, в которых вы когда либо находились? Точняк.


Кристер Эриксон красиво сказал об этом здесь:


Цель программиста в том, чтобы делать поставки в срок и в рамках бюджета. Не «писать код». Имхо, большинство сторонников современного C++ 1) придают чрезмерное значение исходному коду вместо 2) времени компиляции, отладки, когнитивной нагрузки, создаваемой новыми концепциями и добавленной сложностью, требованиями проекта, и так далее. Решает пункт 2.

И да, люди, обеспокоенные состоянием C++ и стандартных библиотек, конечно, могут объединить усилия и попытаться улучшить их. Некоторые так и делают. Некоторые слишком заняты (или они так думают) чтобы тратить время на комитеты. Некоторые игнорируют куски стандартов и делают свои собственные параллельные библиотеки (вроде EASTL). Некоторые пришли к выводу, что C++ уже не спасти, и пытаются сделать собственные языки (Jai) или перепрыгнуть на другую лодку (Rust, подмножества C#).


Принимаем и даём обратную связь


Я знаю, насколько это неприятно, когда «куча озлобленных людей в интернете» пытается сказать, что вся твоя работа — лошадиного навоза не стоит. Я работаю, возможно, над самым популярным в мире игровым движком, которым пользуются миллионы, и часть из них любит говорить, прямо или непрямо, насколько он отвратительный. Это тяжело; я и другие коллеги вложили в это столько раздумий и усилий, и вдруг кто-то проходит мимо и говорит, что мы тут все идиоты и наша работа — мусор. Печально!


Скорей всего, что-то подобное испытывает каждый, кто работает над C++, STL или любой другой широко используемой технологией. Они годами работали над чем-то важным, и тут куча Разъярённых Жителей Нижнего Интернета пришла и расфигачила твою любимую работу.


Слишком легко перейти в защитную позу, это наиболее естественная реакция. Обычно — не самая конструктивная.


Если не обращать внимания на буквальных троллей, которые ноют по интернетам просто для удовольствия, большинство жалобщиков действительно имеют проблемы или неприятности, стоящие за ними. Они могут плохо это формулировать, или преувеличивать, или жалобщик не подумал над множеством других точек зрения кроме своей собственной, но тем не менее, существует вполне конкретная проблема, лежащая за всеми этими проявлениями.


Что я делаю, когда кто-то жалуется на вещь, над которой я работал? Нужно забыть о «себе» и «своей работе», и принять их точку зрения. С чем они пытаются разобраться, какие проблемы пытаются решить? Задача любого софта/библиотек/языков — помочь пользователям решить их проблемы. Это может быть или идеальный инструмент для решения этих проблем, или «ок, это может сработать», или совершенно ужасно плохое решение.


  • «Я очень упорно над этим работал, но да, похоже что мой инструмент не очеь хорош в решении ваших проблем» — это совершенно правильный исход!
  • «Я очень упорно над этим работал, но не знаю или не учёл ваших потребностей, давайте я разберусь, что здесь можно сделать» — это тоже отличный исход!
  • «Простите, я не понимаю вашей проблемы» — тоже подойдёт!
  • «Я очень упорно над этим работал, но похоже, ни у кого нет проблем, которые решает моя работа» — очень печальных исход, но он может случиться, и случается на практике.

Некоторые из ответов вида «весь фидбек будет проигнорирован, если он не оформлен в виде документа, представленного на собрании комитета C++», которые я видел в последнее время не кажутся мне продуктивным подходом. Точно так же, защита архитектуры библиотеки с помощью аргумента вида «это была популярная бибилиотека в Boost!» не учитывает той части мира C++, которая не считает, что Boost — это что-то хорошее.


Индустрия видеогейминга, если смотреть глобально, тоже виновата. Игровые технологии традиционно создаются с помощью C или C++ просто потому, что вплоть до самого последнего времени остальные системные языки программирования просто не существовали (но теперь есть как минимум Rust, составляющий достойную конкуренцию). Учитывая ту зависимость от C++, в которую попала индустрия, она совершенно точно не проделала достаточной работы, чтобы её замечали, и не занимается достаточно улучшением языка, библиотек и экосистемы.


Да, это тяжелая работа, и да — жаловаться в интернете куда проще. И кто бы ни начал работать над будущим C++, это самое будущее не в решении «непосредственных проблем» (вроде поставки игры или чего-то такого); они должны работать над чем-то куда более долговременным. Существуют компании, которые могут это позволить; любая компания, производящая большой игровой движок или большой издатель с централизованной технологической группой совершенно точно может этим заняться. Если это будет стоить того, но знаете, это как-то лицемерно, говорить «C++ — фигня полная, нам это не нужно», и при этом никогда не доносить разработчикам языка, что же вам нужно.


Моё впечатление от всего этого в том, что большинство игровых технологий чувствуют себя достаточно хорошо с последними (C++11/14/17) нововведениями в сам язык C++ — например, полезными оказались лямбды, constexpr if очень крут, и так далее. Но есть тенденция игнорировать то, что добавилось в стандартные библиотеки, как по причине описанных выше проблем в архитектуре и реализациях STL (долгое время компиляции, плохая производительность в отладке), так и просто потому, что они эти дополнения недостаточно вкусные, или компании уже написали свои собственные контейнеры/строки/алгоритмы/… многие годы назад, и не понимают, зачем им менять то, что уже работает.


Минутка рекламы. 19-20 апреля в Москве состоится конференция C++ Russia 2019. Будет множество хардкорных докладов, всё как вы любите. Один из наших гостей — Arno Schödl, отличный докладчик и CTO компании Think-Cell, расскажет про «Text Formatting For a Future Range-Based Standard Library». Искали место, где можно обсудить ренжи и другие новые фичи? Вы его нашли. Как попасть на конференцию, можно узнать на официальном сайте (с первого февраля цены на билеты повысятся).
Tags:c++c++ russiac++ russia 2019ranges
Hubs: JUG Ru Group corporate blog High performance C++ Game development Algorithms
+103
57.9k 163
Comments 238
Information
Founded

25 March 2012

Location

Россия

Website

jugru.org

Employees

51–100 employees

Registered

22 August 2013

Habr blog