Кто виноват в этом диапазоне, основанном на ссылке на временный?

15

Следующий код выглядит довольно безобидным на первый взгляд. Пользователь использует функцию bar()для взаимодействия с некоторыми функциями библиотеки. (Это могло работать даже долгое время, так как bar()возвращало ссылку на временное значение или подобное.) Однако теперь оно просто возвращает новый экземпляр B. Bснова имеет функцию, a()которая возвращает ссылку на объект повторяемого типа A. Пользователь хочет запросить этот объект, что приводит к segfault, поскольку Bвозвращаемый временный объект bar()уничтожается до начала итерации.

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

(С этим может быть связан вопрос: следует ли установить общее правило, согласно которому код не должен «основываться на диапазоне для итерации» над чем-то, что извлекается более чем одним соединением вызовов в заголовке цикла, поскольку любой из этих вызовов может вернуть Rvalue?)

#include <algorithm>
#include <iostream>

// "Library code"
struct A
{
    A():
        v{0,1,2}
    {
        std::cout << "A()" << std::endl;
    }

    ~A()
    {
        std::cout << "~A()" << std::endl;
    }

    int * begin()
    {
        return &v[0];
    }

    int * end()
    {
        return &v[3];
    }

    int v[3];
};

struct B
{
    A m_a;

    A & a()
    {
        return m_a;
    }
};

B bar()
{
    return B();
}

// User code
int main()
{
    for( auto i : bar().a() )
    {
        std::cout << i << std::endl;
    }
}
hllnll
источник
6
Когда вы выяснили, кто виноват, каким будет следующий шаг? Кричать на него / нее?
JensG
7
Нет, с чего бы я? На самом деле мне больше интересно знать, где мыслительный процесс разработки этой «программы» не смог избежать этой проблемы в будущем.
Hllnll
Это не имеет ничего общего с r-значениями или циклами, основанными на диапазоне, но с тем, что пользователь неправильно понимает время жизни объекта.
Джеймс
Сайт-замечание: это CWG 900, которая была закрыта как не дефект. Может быть, в протоколе есть обсуждение.
DYP
8
Кто в этом виноват? Бьярн Страуструп и Деннис Ричи, в первую очередь.
Мейсон Уилер

Ответы:

14

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

Заставить временную жизнь достаточно долго и больше не делать это чрезвычайно сложно. Даже довольно временное «все временные эффекты, вовлеченные в создание диапазона для диапазона, основанного на действии до конца цикла», не будут иметь побочных эффектов. Рассмотрим случай B::a()возврата диапазона, независимого от Bобъекта, по значению. Тогда временный Bможет быть немедленно отброшен. Даже если бы можно было точно определить случаи, когда необходимо продление срока службы, поскольку эти случаи не очевидны для программистов, эффект (деструкторы называются гораздо позже) был бы удивительным и, возможно, не менее тонким источником ошибок.

Было бы более желательно просто обнаружить и запретить такую ​​ерунду, заставляя программиста явно повышать уровень bar()до локальной переменной. Это невозможно в C ++ 11 и, вероятно, никогда не будет возможно, потому что это требует аннотаций. Руст делает это, где подпись .a()будет:

fn a<'x>(bar: &'x B) -> &'x A { bar.a }
// If we make it as explicit as possible, or
fn a(&self) -> &A { self.a }
// if we make it a method and rely on lifetime elision.

Вот 'xпеременная или область времени жизни, которая является символическим именем для периода времени, в течение которого ресурс доступен. Честно говоря, времена жизни трудно объяснить - или мы еще не нашли лучшего объяснения - поэтому я ограничусь минимальным необходимым для этого примера и отсылаю склонного читателя к официальной документации .

Средство проверки заимствования заметит, что результат bar().a()должен жить до тех пор, пока работает цикл. Выражаясь как ограничение на время жизни 'x, мы пишем: 'loop <= 'x. Было бы также заметить, что получатель вызова метода bar(), является временным. Два указателя связаны с одним и тем же временем жизни, поэтому 'x <= 'tempсуществует другое ограничение.

Эти два ограничения противоречивы! Нам нужно, 'loop <= 'x <= 'tempно 'temp <= 'loop, который отражает проблему довольно точно. Из-за противоречивых требований ошибочный код отклоняется. Обратите внимание, что это проверка во время компиляции, и код Rust обычно приводит к тому же машинному коду, что и эквивалентный код C ++, поэтому вам не нужно платить за него время выполнения.

Тем не менее, это большая возможность, которую можно добавить в язык, и она работает, только если ее использует весь код. Это также влияет на дизайн API (некоторые проекты, которые были бы слишком опасны в C ++, становятся практичными, другие нельзя сделать так, чтобы они хорошо играли с жизнями). Увы, это означает, что нецелесообразно добавлять в C ++ (или на любой другой язык) задним числом. Таким образом, ошибка заключается в инерции успешных языков и в том, что у Бьярне в 1983 году не было хрустального шара и предвидения, чтобы включить уроки последних 30 лет исследований и опыта C ++ ;-)

Конечно, это не поможет избежать проблемы в будущем (если вы не переключитесь на Rust и никогда не будете использовать C ++ снова). Можно было бы избежать более длинных выражений с несколькими цепочечными вызовами методов (что довольно ограниченно и даже удаленно не устраняет все жизненные проблемы). Или можно попытаться принять более дисциплинированную политику владения без помощи компилятора: четко документировать, что barвозвращается по значению и что результат B::a()не должен переживать то, Bна что a()вызывается. При изменении функции для возврата по значению вместо долговременной ссылки помните, что это изменение контракта . Все еще подвержен ошибкам, но может ускорить процесс выявления причины, когда это произойдет.


источник
14

Можем ли мы решить эту проблему, используя функции C ++?

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

struct foo {
    void bar() & {} // lvalue-ref-qualified
};

foo& lvalue ();
foo  prvalue();

lvalue ().bar(); // OK
prvalue().bar(); // error

При вызове beginфункции-члена мы знаем, что, скорее всего, нам также потребуется вызвать endфункцию-член (или что-то вроде size, чтобы получить размер диапазона). Это требует, чтобы мы оперировали lvalue, поскольку нам нужно обращаться к нему дважды. Поэтому вы можете утверждать, что эти функции-члены должны быть lvalue-ref-qualified.

Тем не менее, это может не решить основную проблему: алиасинг. Функция- член beginи endсоздает псевдоним объекта или ресурсов, которыми управляет объект. Если мы заменим beginи endодной функцией range, мы должны обеспечить один , который можно назвать на rvalues:

struct foo {
    vector<int> arr;

    auto range() & // C++14 return type deduction for brevity
    { return std::make_pair(arr.begin(), arr.end()); }
};

for(auto const& e : foo().range()) // error

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

struct foo {
    vector<int> arr;

    auto range() &
    { return std::make_pair(arr.begin(), arr.end()); }

    auto range() &&
    { return std::move(arr); }
};

for(auto const& e : foo().range()) // OK

Применение этого к делу ОП и небольшая проверка кода

struct B {
    A m_a;
    A & a() { return m_a; }
};

Эта функция-член изменяет категорию значений выражения: B()является предварительным значением, но B().a()является левым значением. С другой стороны, B().m_aэто ценность. Итак, давайте начнем с того, чтобы сделать это последовательным. Есть два способа сделать это:

struct B {
    A m_a;
    A &  a() &  { return m_a; }

    A && a() && { return std::move(m_a); }
    // or
    A    a() && { return std::move(m_a); }
};

Вторая версия, как сказано выше, исправит проблему в OP.

Кроме того, мы можем ограничить Bфункции-члены:

struct A {
    // [...]

    int * begin() & { return &v[0]; }
    int * end  () & { return &v[3]; }

    int v[3];
};

Это не окажет никакого влияния на код OP, так как результат выражения после :цикла for в диапазоне зависит от ссылочной переменной. И эта переменная (как выражение, используемое для доступа к ее функциям beginи endфункциям-членам) является lvalue.

Конечно, вопрос заключается в том, должно ли правило по умолчанию «наложение псевдонимов функций-членов на rvalues ​​должно возвращать объект, который владеет всеми его ресурсами, если только нет веской причины не делать этого» . Возвращаемый псевдоним может быть легально использован, но опасен в том виде, в котором вы его используете: его нельзя использовать для продления времени жизни своего «родительского» временного:

// using the OP's definition of `struct B`,
// or version 1, `A && a() &&;`

A&&      a = B().a(); // bug: binds directly, dangling reference
A const& a = B().a(); // bug: same as above
A        a = B().a(); // OK

A&&      a = B().m_a; // OK: extends the lifetime of the temporary

В C ++ 2a, я думаю, вы должны обойти эту (или похожую) проблему следующим образом:

for( B b = bar(); auto i : b.a() )

вместо ОП

for( auto i : bar().a() )

Временное решение вручную указывает, что время жизни b- это весь блок цикла for.

Предложение, которое ввело это init-заявление

Live Demo

DYP
источник