Skip to content

Latest commit

 

History

History
142 lines (118 loc) · 7.66 KB

rvo_vs_raii.md

File metadata and controls

142 lines (118 loc) · 7.66 KB

(N)RVO vs RAII

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

В C++ есть конструкторы, деструкторы и приходящая с ними концепция RAII: Захватывай и инициализируй ресурс в конструкторе, очищай и отпускай в деструкторе. И будет тебе счастье.

Ну что ж, давайте попробуем!

Сделаем какой-нибудь простенький класс, выполняющую буферизированную запись:

struct Writer {
public:
    static const size_t BufferLimit = 10;

    // захватываем устройство, в которое будет писать
    Writer(std::string& dev) : device_(dev) {
        buffer_.reserve(BufferLimit);
    }
    // в деструкторе отпускаем, записывая все, что набуферизировали
    ~Writer() {
        Flush();
    }

    void Dump(int x) {
        if (buffer_.size() == BufferLimit){
            Flush();
        }
        buffer_.push_back(x);
    }
private:
    void Flush() {
        for (auto x : buffer_) {
            device_.append(std::to_string(x));
        }
        buffer_.clear();
    }

    std::string& device_;
    std::vector<int> buffer_;
};

И попробуем им красиво воспользоваться:

const auto text = []{
    std::string out;
    Writer writer(out);
    writer.Dump(1);
    writer.Dump(2);
    writer.Dump(3);
    return out;
}();
std::cout << text;

Работает!. Печатает 123. Все как мы и ожидали. Как похорошел язык!

Ага. Только работает оно исключительно потому что нам повезло. Тут, начиная с C++17, гарантированные NRVO (named return value optimization) и copy elision. А программа написана вообще-то с очень злобной ошибкой. И если мы возьмем, например, MSVC, который последним стандартам частенько забывает полностью соответствовать. То результат внезапно будет иной.

Если мы чуть-чуть модифицируем программу:

    int x = 0; std::cin >> x;

    const auto text = [x]{
        if (x < 1000) {
            std::string out;
            Writer writer(out);
            writer.Dump(1);
            writer.Dump(2);
            writer.Dump(3);
            return out;
        } else {
            return std::string("hello\n");
        }
    }();
    std::cout << text;

то под clang все еще работает, а под gcc — нет

И самое замечательное во всем этом безобразии, что никакое это не неопределенное поведение!

Помните, мы обсуждали не работающее перемещение? И выясняли, что в C++ нет деструктивного перемещения. А оно все-таки есть. Иногда. Когда срабатывает оптимизация возвращаемого значения и удаление лишних вызовов конструкторов копий/перемещений.

Программы выше все неправильные. Они предполагают, что деструктор Writer будет вызван до возврата значения из функции. Чего никак быть не может. Деструкторы объектов вызываются всегда после возврата из функции. Иначе эти самые значения бы просто умирали, и вызывающий код всегда получал мертвый объект.

Но как же тогда оно иногда работает и скрывает такую печальную ошибку? А вот как:

const auto text = []{
    std::string out;    
    Writer writer(out);  // (2) адреса out и text одинаковые. 
                         // по сути это один и тот же объект 
    writer.Dump(1);
    writer.Dump(2);
    writer.Dump(3);
    return out;     // (1) это единственная точка возврата из функции
                    // NRVO позволяет в качестве адреса временной 
                    // переменной out подложить адрес переменной,
                    // в которую мы запишем результат — text
}(); // (3) деструктор Writer пишет напрямую в text

Без всех хитроумных оптимизаций же происходит следующее:

const auto text = []{
    std::string out;     // (0) строка пуста
    Writer writer(out);  // (1) адреса out и text разные. Это разные объекты 
    writer.Dump(1);
    writer.Dump(2);
    writer.Dump(3);      // (2) записи не происходило — буфер не заполнился
    return out;          // (3) возвращаем копию out — пустую строку
}(); // (3) деструктор Writer пишет в out, она умирает и не достается никому
     // text пуст

Никакого неопределенного поведения тут, повторяю, нет. Просто всякий деструктор/конструктор с побочными эффектами как бы «сломан» из-за разрешенных и описанных в стандарте (и даже иногда гарантированных) оптимизаций.

Ну а в каком-нибудь Rust нам такую ерунду написать просто не дадут. Такие дела.

Исправляется проблема либо вытаскиванием Flush наружу и явным вызовом его. Либо добавлением еще одной вложенной области видимости:

const auto text = []{
    std::string out;
    {
        Writer writer(out);
        writer.Dump(1);
        writer.Dump(2);
        writer.Dump(3);
    } // деструктор Writer вызывается здесь
    return out;
}();
std::cout << text;

Не забудьте только оставить комментарий, чтобы ваши коллеги случайно не удалили такие «лишние» скобочки. И проверьте, что ваш автоформаттер кода также их не удаляет.

Полезные ссылки:

  1. https://en.cppreference.com/w/cpp/language/copy_elision
  2. http://eel.is/c++draft/class.copy.elision