Здравствуйте уважаемые читатели. Данная публикация адресована начинающим разработчикам С++ которые только становятся на путь высокой производительности и "отстрелянных конечностей". Опытные разработчики найдут здесь скорее повторение тех вещей, которые сами мучительно осваивали в те далекие времена, когда в языке С++ появилась возможность удобного перемещения объектов.

Многие из вас уже слышали и надеюсь использовали функцию move() в своих проектах. Если нет, то пришло время с ней познакомиться.

Вопрос: Опять этот move, сколько уже можно? Есть же множество опубликованного материала по этой теме?

Ответ: Да, есть много статей. В свое время учился по ним, в том числе и тут, на Хабре [1, 2]. Но мне все равно было не понятно, значит, учитывая статистику, непонятно также и некоторому количеству читателей.

Как обычно начинаются туториалы по move? Рассмотрим lvalue объект, ему соответствует rvalue объект, между ними есть оператор присваивания (=). Тут появляются ссылки, да не просто, а ссылки на lvalue, на rvalue и пошло-поехало. Мозг перегружается, статья пролистывается до конца. Поэтому попробую рассказать о move c другой стороны - в стиле "от практики к теории" - так, как хотел бы чтобы мне рассказали.

Что обычно говорят о move? Это крутая штука, код с ней работает быстрее. А насколько быстрее? Давайте проверим.

Чтобы оценить быстродействие возьмем следующий класс:

class LogDuration {
public:
    LogDuration(std::string id)
        : id_(std::move(id)) {
    }

    ~LogDuration() {
        const auto end_time = std::chrono::steady_clock::now();
        const auto dur = end_time - start_time_;
        std::cout << id_ << ": ";
        std::cout << "operation time"
                  << ": " << std::chrono::duration_cast<std::chrono::milliseconds>(dur).count()
                  << " ms" << std::endl;
    }

private:
    const std::string id_;
    const std::chrono::steady_clock::time_point start_time_ = std::chrono::steady_clock::now();
};

Не пугайтесь, он нам будет нужен только как условный секундомер для экспериментов. Чтобы с его помощью оценить время выполнения операции достаточно сделать так:

    {
        LogDuration ld("identifier");
        // some operations
    }

где фигурные скобки задают область видимости. При выходе за ее пределы запускаются деструкторы классов для объектов (исключение - статичные переменные), которые были созданы внутри данной области, в том числе и ~LogDuration(), который покажет время выполнения операций внутри блока.

Итак, начнем экспериментировать.

Говорят, что для векторов и строк (std::string) нужно по возможности использовать move. Проверим. Напишем такой код:

int main() {
    vector<uint8_t> big_vector(1e9, 0);

    {
        LogDuration ld("vector copy");
        vector<uint8_t> reciever(big_vector);
    }
    cout << "size of big_vector is " <<  big_vector.size() << '\n';
}

Здесь мы создаем вектор big_vector из нулей длиной 10^9, а затем создаем новый вектор как копию данного. Время на создание копии выводится в консоль:

vector copy: operation time: 484 ms
size of big_vector is 1000000000

Программа valgrind показывает, что за время выполнения программы было использовано 2 ГБ оперативной памяти:

total heap usage: 4 allocs, 4 frees, 2,000,073,728 bytes allocated

Итак, у нас получилось два одинаковых вектора, затрачено полсекунды и 2 ГБ оперативной памяти. Дальше вопрос - а что если исходный вектор нам дальше в коде никогда не понадобится, мы бы сэкономили 1 ГБ. Давайте посмотрим, что будет если добавить move. Произведем замену:

- vector<uint8_t> reciever(big_vector);
+ vector<uint8_t> reciever(move(big_vector));

И о чудо! Время выполнения уменьшилось почти в 10 раз, а размер исходного вектора стал равен нулю:

vector move: operation time: 34 ms
size of big_vector is 0

Valgrind уже более оптимистичен:

total heap usage: 3 allocs, 3 frees, 1,000,073,728 bytes allocated

Получается, что воспользовавшись move мы выиграли в скорости, но пожертвовали исходным вектором. Случай с длинной строкой вместо вектора предлагаю проверить самостоятельно.

Теперь попробуем разобраться что тут вообще происходит. Давайте напишем свой вектор, точнее простую обертку над стандартным вектором

template <typename T>
class Vector {
public:
    Vector(size_t size, T value)
        : data_(size, value) {
    }
    Vector(const Vector& rhs) {
        cout << "copy constructor was called\n";
    }
    Vector(Vector&& rhs) noexcept {
        cout << "move constructor was called\n";
    }
    size_t size() {
        return data_.size();
    }

private:
    vector<T> data_;
};

Также не пугайтесь, тут нужно смотреть на то, что внутри секции public. Добавьте этот код перед main() в вашей программе, а внутри main замените первую букву в слове vector на заглавную везде, где он упоминается. Для случая:

Vector<uint8_t> reciever(big_vector);

в консоли будет выведено:

copy constructor was called
vector copy: operation time: 0 ms
size of big_vector is 1000000000

А для варианта с move:

move constructor was called
vector move: operation time: 0 ms
size of big_vector is 1000000000

Здесь мы подходим к наблюдению, что функция move сама по себе не выполняет никаких перемещений, несмотря на название, а делает все возможное чтобы в данном конкретном примере вызвать конструктор перемещения - Vector(Vector&& rhs). Т.к. в приведенном классе-обертке в конструкторах выполяется только вывод текста, то понятно, что время операции столь мало, а исходный вектор никуда не исчезает.

Использование move не ограничивается конструкторами классов. Например:

void CopyFoo(string text) {}
void CopyRefFoo(const string& text) {}
void MoveFoo(string&& text) {}

int main() {
    string text;
    text = "some text";
  
    CopyRefFoo(text);
    CopyFoo(text);

//    MoveFoo(text); // compile error
    MoveFoo("another text");
    MoveFoo(move(text));

Обратите внимание на строчку 12, где закомментирована операция. Сигнатура данной функции содержит "волшебные" символы && из-за которых не получается ей указать объект text. А какую-то бесхозную строку в кавычках можно. Теперь обратите внимание на строку 7, где объекту text присваивается "some text". Чем они различаются принципиально, кроме расположения лево-право от оператора присваивания?
А тем, что text имеет адрес в памяти, а выражение "some text" его не имеет, точнее его адрес не так просто найти. Адрес постоянного объекта можно узнать так:

cout << &text << '\n';
// 0x7ffdfd45dce0

Теперь смотрите, для того, чтобы функция MoveFoo приняла аргумент, он "не должен иметь адреса", как "another text" например. Такие объекты еще называют временными. Теперь мы можем подойти к тому моменту когда можно сказать, что делает функция move - она делает так, что ее аргумент притворяется "безадресным", т.е. временным, поэтому 14-я строка нормально компилируется. И если внутри функции MoveFoo ничего с text не делать, то он сам по себе никуда не пропадет, не перенесется, не исчезнет. Но зачем же тогда спрашивается все телодвижения? А вот если написать:

void MoveFoo(string&& text) {
    string tmp(move(text));
}

то после выполнения данной функции переменная text во внешнем блоке окажется пустой (компилятор gcc 7.5 c++17), как в самом начале для случая с перемещением вектора.

Теперь вернемся к вопросу почему исходный вектор "переместился" в новый вектор за такое короткое время?
У нас есть некоторые наблюдения: при использовании move памяти затрачивалось практически вровень размеру исходного массива.
Представим вектор как структуру данных, которая в самом упрощенном варианте хранит адрес (указатель) на место в памяти, где находятся все его элементы. Мы же помним, что в векторе все элементы расположены в памяти последовательно, без разрывов. А вторым полем будет переменная, хранящая текущий размер вектора. Также мы знаем, что после операции "перемещения" исходный вектор оказывается пустым. А теперь представьте, что встречаются два вектора - один с набором из 10^9 элементов, второй пустой. Самое простое решение им взять и "обменяться" своим содержимым. Новый просто изменит свой адрес, указывающий на начало блока данных на тот, что был у исходного. Также обновит свой размер. А исходный примет такие же поля от пустого вектора. Все просто. Если пройтись отладчиком по цепочке от конструктора перемещения, то можно обнаружить такой код в стандартной библиотеке в файле stl_vector.h:

    void _M_swap_data(_Vector_impl& __x) _GLIBCXX_NOEXCEPT
    {
      std::swap(_M_start, __x._M_start);
      std::swap(_M_finish, __x._M_finish);
      std::swap(_M_end_of_storage, __x._M_end_of_storage);
    }

Там конечно, все намного сложнее, но общий принцип примерно таков.

Очень надеюсь, что теперь основные моменты использования move для вас прояснились. Дальше рекомендую уже ознакомиться с более научными работами по использованию move семантики, где легко, надеюсь, уловите аналогии с lvalue, rvalue и т.п. А более опытным разработчикам - если дочитали до конца, буду рад услышать Ваши комментарии и замечания.

Комментарии (68)


  1. DistortNeo
    07.11.2021 01:01
    +19

    А мне больше понравилось другое объяснение, ещё более простое:


    Есть в C++ два типа ссылок: обычная & и rvalue &&. Если объект передаётся по rvalue-ссылке, то это значит, что вызывающий код отказывается от владения объектом, и функция может делать с ним всё, что ей хочется. Ну а с помощью std::move мы сообщаем об этом намерении явно.


    1. vkni
      07.11.2021 02:08
      +1

      Я попытался проследить, откуда взялось вообще понятие lvalue и rvalue - оно тянется в глубь веков, ещё до BCPL. И, если честно, определения я так и не смог найти. :-(

      Поэтому всё равно какое-то кол-во магии остаётся.


      1. TargetSan
        07.11.2021 02:43
        +5

        Value categories

        Там вполне чётко написано, что lvalue может находиться слева от оператора присваивания, а rvalue - справа.

        Хотя эту ссылку вы должны были найти раз добрались до BCPL.


        1. DistortNeo
          07.11.2021 13:46
          +1

          Слишком сложно написано, для новичка это все равно, что текст на незнакомом языке.
          Плюс эти правила постоянно меняются: RVO, copy elision, нестандартные оптимизации компилятора.
          Лучше понимать общий принцип.


      1. mayorovp
        07.11.2021 22:16
        +1

        Понятия lvalue и rvalue взялись как раз понятно откуда.


        lvalue — это то, что может стоять слева от знака присваивания
        rvalue — это то, что может стоять только справа от знака присваивания


        1. DistortNeo
          07.11.2021 23:19

          const int a = 2;
          a = 3; // No
          const int b = a; // Yes

          Получается, a — это rvalue?


          1. sergio_nsk
            07.11.2021 23:36

            Нет, а - это const lvalue. Ты же использовал a слева строчкой выше и навесил квалификатор const, дальше уже он не разрешает a находиться слева по семантическим правилам. Есть ещё одно часто используемое упрощённое определение: lvalue - это то, от чего можно взять адрес (&a).


            1. DistortNeo
              08.11.2021 00:24

              Ты же использовал a слева строчкой выше

              Не вопрос, меняю присваивание на явный вызов конструктора:


              const int a { 2 };
              a = 3; // No
              const int b = a; // Yes

              Есть ещё одно часто используемое упрощённое определение: lvalue — это то, от чего можно взять адрес (&a).

              Ну вот смотрите, как я беру адрес от rvalue:


              
              class A {};
              
              A* get_addr(A&& arg)
              {
                return &arg;
              }
              
              int main(int argc, char **argv)
              {
                  auto addr = get_addr(A{});
                  return 0;
              }
              

              При передаче аргумента значение rvalue становится lvalue, т.к. связано с переменной. Ну а дальше спокойно берём указатель и даже не видим предупреждения.


              1. sergio_nsk
                08.11.2021 00:39

                `const int a { 2 }` - это в точности `const int a = 2` - инициализация нового (ранее не инициализированного) объекта.

                > Ну вот смотрите, как я беру адрес от rvalue: `A* get_addr(A&& arg) { return &arg; }`

                Это частая ошибка. Аргументы функции - всегда lvalue. В данном коде это ссылка на rvalue. Конечно можно взять адрес lvalue. Ты берёшь адрес переменной, а не значения rvalue. Это как `int a = 1; int* b = &a;`, сравни `int a = 1; int* b = &(a + 1);`.


                1. DistortNeo
                  08.11.2021 01:29
                  -1

                  const int a { 2 } — это в точности const int a = 2 — инициализация нового (ранее не инициализированного) объекта.

                  Так, речь шла о присваивании, а тут его нет в явном виде.
                  Но да, такое принимается.


                  Ты берёшь адрес переменной

                  Так покажите мне переменную, от которой я беру адрес. Напоминаю, что оператор & здесь берёт не адрес переменной arg, а адрес значения, на которую ссылка ссылается.


                  1. sergio_nsk
                    08.11.2021 01:53
                    +1

                    arg - это локальная переменная фунции. &arg - это адрес переменной arg. Нет такого "адрес значения", оно может быть числом в регистре процессора?


                    1. DistortNeo
                      08.11.2021 02:04
                      -1

                      оно может быть числом в регистре процессора?

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


                      1. mayorovp
                        08.11.2021 12:21

                        Вы сейчас путаете языковую модель и последствия оптимизации компилятором.


    1. emaxx
      08.11.2021 01:07
      +1

      Ваше объяснение некорректно - как минимум, у вас куда-то константные ссылки потерялись... Во многом ведь из-за них весь сыр-бор с std::forward(), коллапсом ссылок в шаблонах и прочими ещё более странными наворотами.

      Кроме того, из вашего объяснения можно получить неверное представление, что якобы из обычной ссылки нельзя сделать rvalue-ссылку (ведь вызывающий код не отказывался от владения, так? однако сделать std::move() в функции никто не запретит, так что получится, что как бы можно "заставить" вызывающий код отказаться от владения?). И вообще, даже с lvalue-ссылкой функция может сделать с объектом что угодно.


  1. NeoCode
    07.11.2021 13:04
    +2

    Не знаю почему, но мне все эти ссылки (и перемещаемые, и обычные) интуитивно не нравятся. Не проще ли явно передавать указатель (который всегда 4 или 8 байт) и делать с объектом что угодно по указателю?
    Я увлекаюсь дизайном языков программирования, и вот как раз недавно собирал информацию по ссылкам. Ссылки (обычные) появились в С++ для того, чтобы можно было возвращать из функции что-то, что может быть слева от присваивания (LValue). Это нужно для перегрузки операторов =, *, [], ++, --. Заодно появилась возможность писать что-то вроде foo() = 42.
    В Rust таких неявных ссылок нет (там понятие «ссылка» есть, но это скорее просто разновидность указателя с явным разыменованием). Перегрузка lvalue-операторов есть, возвращают они ссылки, требующие явного разыменования, и тем ни менее как-то выкрутились. Например перегрузка индексации.
    Передача аргументов по ссылке в С++ происходит неявно, что не очень хорошо с точки зрения наглядности. В C# используется слово ref для явной передачи по ссылке, и читаемость такого кода значительно лучше. Кстати на Хабре есть статья, в которой перечисляются и другие недостатки…
    Ну и move-семантика. Ее назначение понятно, но вот реализация… чисто синтаксически это выглядит как-то криво и громоздко. std::move которая на самом деле ничего не делает, а просто пытается изменить тип аргумента. А есть еще std::forward. Все это производит впечатление какой-то кривизны…
    Интересно, можно ли сделать лучше? Пусть не в рамках С++, а вообще?


    1. amarao
      07.11.2021 13:10
      -1

      При том, что деконструкция в Rust выворачивает мозг в процессе обучения, после Rust семантика С++ выглядит как общение с пациентом ПНД - он что-то хочет сказать, иногда это осмысленно, но так коряво и ужасно (а иногда и не осмысленно), что просто ой.


      1. NeoCode
        07.11.2021 13:38
        +1

        Rust тоже сложный и навороченный, там есть недостатки, хотя за счет того, что он создавался с оглядкой на огромный опыт С++, там больше продуманности.
        Вообще мне хочется придумать идеальный синтаксис и семантику для ссылок всех видов. Но пока не получается…


        1. amarao
          07.11.2021 14:15

          Сравнивая С++ и Rust, Rust - это C++ из которого убрали "С". Стало резко проще и логичнее. При том, что нюансы тонкие и требуют внимания, они больше не ведут к "упс, ну тут дефолт из-за совместимости со старыми версиями, которые совместимы со старыми версиями, которые до сих пор не знают какого размера int".


      1. KivApple
        07.11.2021 15:19
        +4

        Rust до сих пор не научился в placement new. Как следствие, нельзя создать большой объект сразу в куче, он обязательно будет создан сначала на стеке (который при желании можно даже переполнить) и скопирован. Вот когда завезут placement new, тогда и поговорим. А пока это царство memcpy.


        1. amarao
          07.11.2021 16:18

          А вот это, кстати, интересный поинт. Я никогда не думал про такой вопрос. В целом, у Box, всё, что uninit помечено как nightly... Я не знаю, этого достаточно или ещё что-то нужно?


          1. KivApple
            07.11.2021 17:48

            Если в плюсах сделать new MyClass(...), то объект инициализируется сразу в куче. Окей, в безопасном Rust нету прямого аналога new, скорее корректно говорить про то, что Box похож на unique_ptr из STL. В STL есть make_unique, который опять же сначала выделит память в куче, а затем уже проведёт инициализацию объекта сразу в куче.

            В Rust ты можешь без unsafe и сторнних либ, обрабатывающих только частные случаи, сделать только Box::new(MyClass::new(...)). При этом объект обязательно сначала создасться на стеке, а потом скопируется в кучу. Обычно это просто замусоривает код вызовами memcpy под капотом (копирование со стека на стек компилятор ещё умудряется оптимизировать обычно, но вот с Box он бессилен, проблема более фундаментальная, потому что вызов MyClass:new происходит раньше вызова Box::new и тут одним тюнингом оптимизатора без лютых костылей не справиться), но если MyClass очень большой (например, внутри него содержится массив крупной размерности и при этом мы не хотим делать лишний indirection выделяя его отдельно, к тому же это сильно ухудшает выразительность языка, потому что из стандартной библиотеки динамически аллоцированному массиву соответствует Vec, а он имеет переменную длинну и мы лишаемся гарантий размера массива в compile-time, аналога array из STL опять же не завезли в стандартную библиотеку)., то можно и переполнение стека словить.

            Также placement new нужен ещё для реализации кастомных аллокаторов (например, арена объектов), в embedded и т. д.

            При этом сам по себе placement new не выглядит как что-то принципиально unsafe, при отсутствии других unsafe-вызовов, его корректность отлично можно провалидировать.

            Ну вот и выходит, что из-за его отсутствия zero cost abstractions перестают быть zero cost. Runtime проверку границ массивов и прочие assert-ы штатными опциями компиляции отключить можно (и для релизной сборки чего-то критичного к скорости именно так и делают), а заставить оптимизировать placement new нельзя. В итоге целое подмножество алгоритмов и структур данных на Rust становятся by design менее эффективными, чем на C/C++, хотя сами алгоритмы никакими хаками на грани undefined behavior не являются.


            1. mayorovp
              07.11.2021 22:21
              +2

              из стандартной библиотеки динамически аллоцированному массиву соответствует Vec, а он имеет переменную длинну и мы лишаемся гарантий размера массива в compile-time

              Можно же Box на массив фиксированной длины делать.


            1. AnthonyMikh
              08.11.2021 14:35
              +2

              При этом сам по себе placement new не выглядит как что-то принципиально unsafe

              А это и есть принципиально unsafe, конструктор получает на руки неинициализированный кусок памяти и должен каким-то образом установить значения этих полей. То у нас есть неловкий промежуток времени, когда объект вроде бы и есть, но что-то делать с ним нельзя. В частности, нельзя вызывать никакие методы, потому что они могут прочитать неинициализированные значения.


              Впрочем, про ситуацию с конструкторами уже всё расписал Кладов


              1. KivApple
                09.11.2021 10:09
                +1

                Да, в Rust нет конструкторов, MyClass::new просто возвращает сконструированный экземпляр реализуя что-то вроде паттерна фабрика. Соответственно, инициализация происходит атомарно и как бы safe.

                Но, насколько я помню из ABI C/C++ (и я очень сомневаюсь, что в Rust что-то отличается), функция возвращающая структуру на самом деле неявно просто принимает указатель на область памяти, куда нужно положить результат, а эта область памяти выделяется вызывающей функцией, по той банальной причине, что через регистры произвольную структуру вернуть невозможно, они сильно ограничены по размеру и количеству.

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


              1. KivApple
                09.11.2021 10:34
                +1

                Собственно, проверил: https://godbolt.org/z/676jozzxq

                Можно обратить внимание, что, конечно, функция конструирует объект в своей локальной стековой области, но в конце вызывает memcpy, который копирует результат инициализации по адресу, переданный в RDI (он сохраняется в стек вначале функции, потому что перезаписывается вызовом memset, а потом восстанавливается к исходному значению) вызывающей функцией. То есть память была выделена вызывающей функцией. И ничего бы не случилось, если бы в RDI вместо адреса со стека пришёл бы адрес из кучи.

                Теперь включаем оптимизации https://godbolt.org/z/GvWTYPhqc и всё получается вообще красиво - memcpy исчезает, объект инициализируется inplace, прямо по тому адресу, который был передан вызывающей функцией.

                То есть на уровне ABI любой new в Rust вполне себе placement, как и в C/C++.

                Вопрос в том, можем ли мы как-то контролировать, что Rust передаст при вызове функции в качестве этого адреса буфера. Вполне себе можем - https://godbolt.org/z/oY5oGhY51

                Копирования и аллокации на стеке не произошло, но нам пришлось обратиться к unsafe и сделать в три шага то, что по сути реализовано внутри Box::new.

                То есть проблема не в самом языке, а в стандартной библиотеке Rust, которая не содержит аналога std::make_unique из C++, которая бы обеспечивала вызов конструктора (или функции возвращающей сконструированный объект - как мы выяснили выше, на уровне ABI они ничем от конструкторов не отличаются) после аллокации памяти, а не до неё, чтобы оптимизатор смог убрать копирование и лишнюю аллокацию на стеке. Текущий вариант обязывает сначала вызвать конструктор, сохранить результат на стеке вызывающей функции, а уже затем вызвать Box::new передав этот самый результат в качестве аргумента. А нужен макрос (в C++ make_unique является шаблоном), который бы унёс реальный вызов конструктора внутрь Box::new или что-то такое (при этом, насколько я помню, макросы в Rust совершеннее плюсовых шаблонов в том плане, что не будет проблем из-за прав доступа к "методу-конструктору", в плюсах make_unique не работает с приватными конструкторами).

                Проблема в том, что для C++ make_unique является идеоматическим способом аллокации с "умными указателями". За std::unique_ptr(MyClass(...)) будут очень косо смотреть. А в Rust именно Box::new(MyClass::new(...)) является идеоматическим способом создания объекта в куче и уже огромные тонные кода написаны именно так, потому что авторы стандартной библиотеки не продумали этот ньюанс (ну или хотели "как угодно, лишь бы не как в C++"). Вторая плохая новость в том, что в Rust вызов макросов принципиально отличается от вызова функций, то есть по-тихому переписать Box::new как макрос без потери совместимости со старом кодом нельзя.

                Конечно, теоретически у авторов Rust есть альтернатива - вставить костыль в компилятор, который бы переупорядочивал аллокацию и вызов конструктора. В целом они вполне могут это сделать, Box::new и так работает на "магии" через ключевое слово box, которые скрывает всю реализацию в недрах компилятора.

                P. S.: Все эти рассуждения неприменимы для объектов размером 10-20 байт, потому что они умещаются в регистры и аллокаций на стеке принципиально не происходит, да и ABI передачи результата функций получается через регистры, а не через предаллоцированную область памяти. С такими объектами никаких проблем нет, потому что и риска переполнения стека они не несут, и их копирование не несёт оверхеда (потому что доступ к регистрам гораздо дешевле доступа к памяти).

                P. P. S.: Проблема в том, что в Rust программисту надо постоянно держать в уме сколько байт будет весить объект и при необходимости переходить на аллокацию в 3 строчки (две из которые unsafe) вместо 1. Либо аллоцировать вообще всё этим способом (минусов этот способ, кроме многословности не имеет, unsafe лишь значит, что компилятор не может проверить корректность автоматически, но конкретно эти три строчки абсолютно безопасны, более того, внутри стандартной библиотеки вполне себе встречается unsafe), либо объявить свой макрос "Box::new здорового человека" (но динамическая аллокация объектов в куче - базовый функционал любого языка, у которого в принципе есть куча, и это очень странно, если программист должен писать свой велосипед в этой ситуации, тем более что в Rust изначально "батареек" гораздо больше, чем в плюсах).


                1. TargetSan
                  09.11.2021 12:14

                  У меня чем дальше, тем чаще возникает вопрос. А так ли уж важно иметь явную inplace инициализацию везде? В большей части прикладного кода в общем-то по барабану, будет там лишний memcpy на пару сотен байт или нет. RVO в релизе прекрасно работает и покрывает 95% случаев. Ну а если прям вот совсем нужен inplace init, можно в отдельных местах unsafe.
                  Ещё следует учесть, что поддержку оператора box нужно будет делать не только для типа Box, но везде, где происходит выделение не на стеке.


                  1. KivApple
                    09.11.2021 12:41

                    Достаточно все функции аллокации не на стеке делать через макросы. Как в плюсах есть шаблоны make_unique, make_shared и т. д. Чтобы вызов конструктора/функции инициализации смог выполниться позднее выделения памяти. Сейчас же стандартная библиотека Rust генерирует код аллокации принципиально неоптимизируемый, ради чего?

                    Никакой безопасности или выразительности это не даёт (если вставить потроха Box::new в виде вызова оператора box, можно обойтись без unsafe, unsafe версия на nightly функциях тоже unsafe только для компилятора, для человека очевидно, что всё безопасно). Просто разработчики не учли кейс изначально (или очень боялись передрать паттерн из плюсов), а сейчас у них затруднительная ситуация - либо вставлять костыль в компилятор, либо ломать обратную совместимость (Box::new нельзя тихо и незаметно сделать макросом, потому что у них другой синтаксис вызова). Действительно, потери для 95% случаев незначительны, но это не zero cost abstractions, и не оверхед ради безопасности, это просто просчёт дизайна одной из базовых функций языка (выделение памяти в куче).

                    Вот когда такие детские болезни (а это именно оно - потеря производительности без какого-либо выигрыша) исправят (красиво или хотя бы костылями), тогда и поговорим.


                    1. TargetSan
                      09.11.2021 13:36

                      Сейчас же стандартная библиотека Rust генерирует код аллокации принципиально неоптимизируемый, ради чего?

                      Как вы сами показали, компилятор вполне способен такое оптимизировать.


                      Просто разработчики не учли кейс изначально

                      Насколько мне известно, оператор box в каких-то вариантах существует с незапамятных времён. В целом, дискуссия по placement box активно велась ЕМНИП года два или три назад.


                      или очень боялись передрать паттерн из плюсов

                      Дело скорее в отсутствии концепции конструктора. В таком случае любой случай allocate then init становится unsafe.


                      Последнее, что нашлось — Placement by return, эксплуатирующий (Named) Return Value Optimization и Guaranteed Copy Elision. С моей точки зрения, это более элегантный подход, не требующий специального синтаксиса. Единственная проблема — RFC висит уже больше года. Но обсуждение идёт, последний комментарий был 25 дней назад.


                  1. KivApple
                    09.11.2021 12:49

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


                    1. TargetSan
                      09.11.2021 13:39

                      И что значит незначительная просадка? Это почти удвоение

                      По сравнению с затратами на саму аллокацию. Плюс вы сами привели примеры, что компилятор вполне способен "прожевать" не слишком сложные случаи и выполнить RVO.


              1. KivApple
                09.11.2021 10:58
                +1

                Кстати, можно обойтись без unsafe, если использовать магический оператор box напрямую: https://godbolt.org/z/73d44dPMs. Однако его стабилизация под огромным вопросом.


    1. DistortNeo
      07.11.2021 13:44

      Верно, проблема ссылок в их неявности. В C#, например, о намерении передать аргумент по ссылке, допускающей изменение объекта, надо указывать не только в заголовке функции, но и каждый раз при вызове функции.


      1. NN1
        07.11.2021 16:22

        С появлением in всё стало немного сложнее :)
        Так как реализация это просто передача по ссылке и дополнительный атрибут, C++/CLI его попросту игнорирует и позволяет изменить объект неявно.

        class Program
        {
            public static void Main()
            {
                int i = 2;
                new A().F(i);
        
                Console.WriteLine(i); // 1
            }
        }
        public ref class A
        {
        public:
        	A(){}
        	void F([System::Runtime::CompilerServices::IsReadOnlyAttribute] int% a)
        	{
        		a = 1; // OK
        	}
        };

        Ещё одно применение для P/Invoke, чтобы не писать ref.
        Следует использовать с умом, не стоит преподносить сюрпризов лишний раз :)

        [DllImport("abc")]
        public static extern F(in POINT pt);


        1. DistortNeo
          07.11.2021 16:46

          С появлением in всё стало немного сложнее :)

          Под капотом — да. Но если не вдаваться в подробности и не стрелять себе в ногу PInvoke, System.Runtime.CompilerServices.Unsafe. и генерацией IL-кода в райтайме, но никаких проблем не будет. Максимум — просадки в производительности из-за копирования структур.


          1. NN1
            07.11.2021 18:07

            У меня в примере нет ничего из вышеперечисленного.

            Всего лишь язык неподдерживающий «in» также как C#.


            1. DistortNeo
              07.11.2021 19:04

              Ну так у вас код не на C#, а на мёртворождённом Managed C++.


              1. NN1
                07.11.2021 19:31

                Это C++/CLI, а учитывая поддержку .NET 6, немного рано говорить про смерть :)


                1. DistortNeo
                  07.11.2021 19:50
                  -1

                  Это C++/CLI,

                  Странно. Мы всегда его называли Managed C++.


                  а учитывая поддержку .NET 6, немного рано говорить про смерть :)

                  Мне казалось, что его поддержка закончилась на .NET Framework.
                  В любом случае, странно использовать язык, позволяющий стрелять в ногу, а потом удивляться, что нога простреленная.


                  1. mayorovp
                    07.11.2021 22:24
                    +1

                    Managed C++ — это там где ключевое слово __gc используется.
                    А у C++/CLI синтаксис не такой страшный.


        1. KivApple
          07.11.2021 18:55

          А зачем MS ввели в плюсы процентные ссылки вместо вполне себе существовавших обычных ссылок (&) ? Заодно никаких выстрелов в ногу, потому что in был бы const & без всяких дополнительных аннотаций и компилятор запретил бы присваивание.


          1. DistortNeo
            07.11.2021 19:02
            -1

            Чтобы различить ссылки на управляемую и неуправляемую память.
            Но вообще, Managed C++ — мёртвый язык.


    1. artemisia_borealis
      07.11.2021 14:30

      да, в C++ чёрт ногу сломит.
      Там ещё есть правила сворачивания ссылок (когда левая ссылка всегда «побеждает»), понятие универсальной ссылки…

      А std::forward фактически это вот такой не C++ код (но более понятный :) )

      template <typename Fun, typename Arg> decltype(auto)
      bar(Fun fun, Arg&& arg)
      {
         if(arg is rvalue)
            return fun(move(arg));
         else
            return fun(arg)
      }
      , что эквивалентно
      template <typename Fun, typename Arg> decltype(auto)
      bar(Fun fun, Arg&& arg)
      {
         return fun(std::forward<Arg>(arg))
      }

      т.е. здесь игра как раз идёт на том, что ссылка по правилу сворачивания может оказаться rvalue или rvalue. Это т.н. perfect forwarding.


      1. NeoCode
        07.11.2021 14:38

        Мне вот интересно — это всё следствие введения в язык концепции ссылок как таковой? Можно ли было этого избежать, если изначально сделать как-то по другому, и если да то как?


        1. DistortNeo
          07.11.2021 16:50

          Это всё следствия инкрементального усложнения языка, когда в язык добавляли новый функционал, но сохраняя обратную совместимость.


          1. NeoCode
            07.11.2021 18:31
            +1

            Это понятно:) Интересно — а можно ли в принципе реализовать все то, что реализовано в С++, без сохранения обратной совместимости, но и без костылей?


            1. DistortNeo
              07.11.2021 19:00
              -1

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


              1. NeoCode
                07.11.2021 21:22
                +1

                Можно, но язык, скорее всего, повторит участь D.
                Интересно как именно.
                У меня ведь чисто академический, а не коммерческий интерес. Мне достаточно знать, как именно могла бы быть устроена идеальная модель ссылок, чтобы через нее понимать преимущества и недостатки реальных моделей в реальных языках. Хочется видеть систему, а пока я вижу какую-то мешанину — и в С++, и не только в нем.


                1. DistortNeo
                  08.11.2021 02:00
                  +1

                  Проблема заключается в том, что выживают только языки, к которым имеется коммерческий интерес. А идеальные языки представляют интерес исключительно академический. Как в анекдоте про шашечки или ехать.


                  Как по мне, так жизненный цикл всех популярных языков идёт по схеме:


                  1. Стадия хайпа. Кто-то решает создать новый крутой язык, который решит все проблемы современности. При этом язык должен быть простым и иметь низкий порог вхождения, но одновременно решать одну из наиболее острых проблем с другими языками.


                  2. Стадия роста. Язык быстро набирает популярность. Программисты набираются опыта, и функционала языка им начинает не хватать. Это приводит к закономерному развитию языка: разрастанию функционала, добавлению языковых конструкций.


                  3. Стадия угасания. По мере развития языка возрастает его сложность и повышается порог вхождения — это приводит к снижению популярности языка у начинающих. Также снижается гибкость языка из-за груза legacy, всё сложнее становится добавлять функционал и реагировать на потребности. Язык теряет популярность и у сеньоров.



                  Далее возвращаемся к пункту 1: появляется новый язык ...


                  1. NeoCode
                    08.11.2021 19:37

                    Да я не против, согласен с вами. Но мы говорим о разных вещах.
                    У меня чисто академический интерес: как должна выглядеть идеальная система ссылок в идеальном языке программирования, на котором было бы идеально удобно писать программы, который бы объединял достоинства и исключал недостатки существующих реализаций. Есть такой язык в природе или его нет и никогда не будет — мне не важно.


      1. DistortNeo
        07.11.2021 16:48

        Вы, кстати, ошиблись. Здесь arg — это всегда lvalue, а вот тип arg может быть как lvalue reference, так и rvalue reference. Собственно, из-за этого и нужен костыль в виде std::forward.


    1. emaxx
      08.11.2021 02:39

      Есть несколько статей, которые предлагают "destructive move" в качестве альтернативной модели. Это как раз то, что используется в Rust.

      https://www.thecodedmessage.com/posts/cpp-move/

      https://www.foonathan.net/2017/09/destructive-move/


    1. khim
      08.11.2021 03:42

      Пусть не в рамках С++, а вообще?

      Конечно можно. В Rust лучше сделано. Но это всё добавляли уже когда была куча кода написана.

      Вот тут есть статья, объясняющая почему и как перемещение сделано в C++ и в Rust.


    1. 4eyes
      08.11.2021 19:38
      +2

      С указателями есть пачка своих граблей, особенно с точки зрения читаемости кода, т.к. они почти всегда гораздо гибче и иногда эта гибкость не нужна:

      • указатель может быть равен nullptr. Но не всегда, есть код где nullptr никогда не будет присвоен указателю. Глядя на сигнатуру функции не понятно, обрабатывает ли она nullptr, а глядя на параметр внутри функции не понятно, могут ли в него передавать nullptr.

      • семантика владения: указатель может отражать владение объектом (а значит, кто-то должен потом освободить ресурс, на который он указывает), а может и не владеть. Ссылка не владеет тем, на что указывает, за редкими исключениями.

      • указатель может быть изменен, другими словами, то, что указатель был инициализирован в конструкторе, не гарантирует что ниже по коду его никто не сменил. Есть константные указатели на не-константу, конечно, но не все соблюдают const-correctness настолько хорошо.

      Кроме того, указатель - это отдельная сущность, N байт адреса того, на что указываем. И взятие указателя лишает нас информации о размере массива, что бывает важно и удобно при передаче массива в шаблонный метод:

      uint8_t arr[3];        // sizeof() == 3
      uint8_t* parr = arr;   // sizeof() == sizeof(uint8_t*)
      auto& ref = arr;       // sizeof(ref) == 3

      Lifetime extension для указателей на локальную переменную не изобрели. Для константных и rvalue-ссылок этим, наоборот, можно пользоваться. Хотя вопрос стиля, конечно, спорный.


    1. fallenworld
      12.11.2021 09:19

      Ссылка тоже всегда 4 или 8 байт, а нужна она потому что гарантирует неравенство nullptr. Тоесть если вы передаёте указатель вы обязательно должны добавлять эту проверку перед разименоввыванием


  1. beerware
    07.11.2021 15:28
    +2

    В конечном счете, на уровне ассемблера нет вообще никаких ссылок. Все делается через указатели (адреса). И если объект не нужен, просто отдаем его адрес в памяти.

    Ссылочный механизм создал дополнительный уровень сложности, что породило семантику перемещения которую как не объясняй - недосказанность остается. А ведь все очень просто: единственная реальность это указатель.


    1. insecto
      07.11.2021 17:38
      +1

      Ну это вы слишком упростили, это другой уровень абстракции. Под капотом может никакого указателя и не быть, его может вымыть оптимизатором.


    1. KivApple
      07.11.2021 18:21

      Мне кажется, что проще всего объяснить, что ссылка это тот же самый указатель, только после инициализации все обращения к p превращаются в обращения к *p (из-за этого ссылку уже нельзя переприсвоить, потому что присваивание произойдёт над значением, на которое она ссылается как если бы мы сделали *p = x). Применение ссылок - более малословная альтернатива указателям в ситуациях, когда нам нет необходимости в возможности изменить на какой объект ссылается указатель. Этого знания достаточно для 90% применений ссылок. Остаётся ещё rvalue (&&). Но с ним тоже всё просто - он лишь заставлят выбирать правильные перегрузки функций. А с семантической точки зрения аргумент функции типа && означает, что передавшему объект больше не нужен и его можно "портить" (при условии, что после "порчи" деструктор этого объекта сможет корректно отработать) в интересах вызванной функции (чаще всего, чтобы забрать содержимое объекта без копирования куда-то в другое место, но не обязательно). В целом "портить" можно и любую другую неконстантную ссылку, но более выразительно и правильно делать так именно с rvalue, чтобы не вызывать удивления у коллег (к тому же многие стандартные классы и функции имеют именно такое поведение и удобно быть с ним консистентным).


  1. slonopotamus
    07.11.2021 15:42

    а выражение "some text" его не имеет, точнее его адрес не так просто найти и оно недолгоживущее.

    const char* s = "some text";

    printf("адрес, который не так просто найти: %p", s);

    И что значит "недолгоживущее"? Строка зашита в бинарь программы, куда ещё дольше жить?


    1. KudryashovDA Автор
      07.11.2021 17:23

      Спасибо за замечание.
      Имелось ввиду адрес объекта "some text", т.е. вот так:

      cout << &"some text";

      Выглядит совсем нелогично, понимаю, но такой пример часто приводят на тематических сайтах.

      Про "недолгоживущие" - убрал, действительно вводит в заблуждение.


  1. reatfly
    08.11.2021 00:40
    +1

    Новички, узнавшие про move, очень любят его применять по делу и без, например в функции

    std::string GetText() {
      std::string s = "Hello, World!";
      return std::move(s);
    }

    Объяснение простое - move() не копирует объект, он быстрый, поэтому надо вызвать move(), чтобы наверняка!
    А плохо здесь то, используя move() для возврата из функции, явно указывается, что должен быть использован конструктор копирования. Но в языке есть такое понятие, как (N)RVO - (Named) Return Value Optimization, когда объект может быть возращен без вызова каких-либо конструкторов.
    Поэтому никогда не нужно делать move() на возвращаемое значение.


    1. emaxx
      08.11.2021 00:58
      +4

      Ну, новичков можно понять, ведь достаточно малейшего изменения в этом паттерне, чтобы внезапно возникала ненужная копия, и снова требовался бы std::move(). Например, если возвращать не s, а поле x из структуры struct Foo {std::string x;}. И RVO, несмотря на кажущуюся похожесть этого случая, тут не сработает. Всё-таки семантика перемещения в C++ очень неинтуитивна.


    1. DistortNeo
      08.11.2021 01:41

      Но в языке есть такое понятие, как (N)RVO — (Named) Return Value Optimization, когда объект может быть возвращён без вызова каких-либо конструкторов.

      Может, но не должен. То есть на усмотрение компилятора.


      1. mapron
        08.11.2021 15:26

        > Return value optimization is mandatory and no longer considered as copy elision
        C++17.

        upd: хм, эта часть все же не про NRVO, похоже, ошибся.
        Но ощущение что в С++20 он все же будет обязательный так же.


  1. fareloz
    08.11.2021 13:32
    +1

    Где фигурные скобки задают область видимости. При выходе за ее пределы запускаются деструкторы классов для объектов

    Стоит отметить, что деструкторы связаны не с областью видимости, а с временем жизни. В данном случае конечно два понятия ("время жизни" и "область видимости") совпадают, но переменная может быть статичной, например, и тогда область видимости будет той же, а вот жить она будет до конца программы и никакой деструктор не вызовется.


    1. KudryashovDA Автор
      08.11.2021 15:14

      Ценный комментарий, спасибо. Добавил в текст пометку


    1. khim
      08.11.2021 19:25
      +1

      В данном случае конечно два понятия ("время жизни" и "область видимости") совпадают, но переменная может быть статичной, например, и тогда область видимости будет той же, а вот жить она будет до конца программы и никакой деструктор не вызовется.

      Вызовется-вызовается. Ишшо как вызовется. Вы только полюбуйтесь сколько кода генерируется, чтобы он вызвался.

      Аж прям память в __cxa_atexit аллоцируется, чтобы всё правильно вызвалось.


      1. fareloz
        08.11.2021 22:04

        Имелось в виду, что не вызовется при выходе из области видимости, а не при завершении программы. При завершении конечно вызовется


  1. oleg-m1973
    14.11.2021 20:56
    -1

    Vector(size_t size, T value) : data_(size, value) {

    Не надо шаблонные параметры передавать по-значению без необходимости. Здесь должно быть

    Vector(size_t size, const T &value): data_(size, value) {