Вызов нестатической функции-члена вне времени жизни объекта в С++ 17

Имеет ли следующая программа неопределенное поведение в С++ 17 и более поздних версиях?

struct A {
    void f(int) { /* Assume there is no access to *this here */ }
};

int main() {
    auto a = new A;
    a->f((a->~A(), 0));
}

С++ 17 гарантирует, что a->f вычисляется для функции-члена объекта A до оценки аргумента вызова. Следовательно, направление от -> четко определено. Но до того, как вызов функции введен, аргумент оценивается и заканчивает время жизни объекта A (см. однако правки ниже). У вызова все еще есть неопределенное поведение? Можно ли таким способом вызывать функцию-член объекта вне его времени жизни?

Категория значений a->f является prvalue для [expr.ref]/6.3.2, а [basic.life]/7 запрещает только вызовы нестатических функций-членов для glvalues ссылаясь на объект после жизни. Означает ли это, что вызов действителен? (Изменение: как обсуждалось в комментариях, я, вероятно, неправильно понимаю [basic.life]/7, и это, вероятно, применимо здесь.)

Изменится ли ответ, если я заменю вызов деструктора a->~A() на delete a или new(a) A (на #include<new>)?


Некоторые уточнения и уточнения по моему вопросу:


Если бы я разделил вызов функции-члена и деструктор /delete/place-new на два оператора, я думаю, что ответы ясны:

  1. a->A(); a->f(0): UB из-за нестатического вызова члена на a вне его времени жизни. (см. правку ниже)
  2. delete a; a->f(0): то же, что и выше
  3. new(a) A; a->f(0): четко определено, вызов нового объекта

Однако во всех этих случаях a->f секвенируется после первого соответствующего оператора, в то время как в моем первоначальном примере этот порядок обратный. У меня вопрос: позволяет ли это изменение изменить ответы?


Для стандартов до С++ 17 я изначально думал, что все три случая вызывают неопределенное поведение, потому что оценка a->f зависит от значения a, но не является последовательной по отношению к оценке аргумента, который вызывает сторону -эффект на a. Тем не менее, это неопределенное поведение, только если есть фактический побочный эффект от скалярного значения, например запись в скалярный объект. Однако ни один скалярный объект не записан, потому что A тривиален, и поэтому мне также было бы интересно узнать, какое именно ограничение нарушается в случае стандартов до С++ 17. В частности, сейчас мне неясен случай с новым размещением.


Я только что понял, что формулировка о времени жизни объектов изменилась между С++ 17 и текущим проектом. В n4659 (черновик С++ 17) [basic.life]/1 говорит:

Время жизни объекта o типа T заканчивается, когда:

  • если Т является классом тип с нетривиальным деструктором (15.4), вызов деструктора начинается

[...]

в то время как текущий черновик гласит:

Время жизни объекта o типа T заканчивается, когда:

[...]

  • если T является типом класса, начинается вызов деструктора или

[...]

Поэтому я полагаю, что мой пример имеет четко определенное поведение в С++ 17, но не в текущем (С++ 20) черновике, потому что вызов деструктора тривиален, а время жизни объекта A не заканчивается. Буду признателен за разъяснения по этому вопросу. Мой оригинальный вопрос все еще стоит даже для С++ 17 в случае замены вызова деструктора выражением delete или place-new.


Если f обращается к *this в своем теле, то может быть неопределенное поведение для случаев вызова деструктора и выражения удаления, однако в этом вопросе я хочу сосредоточиться на том, действителен ли сам вызов или нет. Однако обратите внимание, что вариант моего вопроса с Placement-New потенциально не будет иметь проблемы с доступом к члену в f, в зависимости от того, является ли сам вызов неопределенным поведением или нет. Но в этом случае может возникнуть дополнительный вопрос, особенно для случая размещения нового, потому что мне неясно, будет ли this в функции всегда автоматически ссылаться на новый объект или может понадобиться потенциально быть std::launder ред (в зависимости от того, какие члены A имеют).


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

Кроме того, с практической точки зрения, тривиальный вызов деструктора, вероятно, не влияет на сгенерированный код и (вряд ли?) На оптимизацию, основанную на неопределенных предположениях о поведении, за исключением того, что все примеры кода, скорее всего, сгенерируют код, который выполняется, как ожидается, на большинстве компиляторов. Меня больше интересует теоретическая, а не практическая перспектива.


Этот вопрос призван лучше понять детали языка. Я не призываю никого писать такой код.

Ответ 1

Это правда, что тривиальные деструкторы вообще ничего не делают, даже не заканчивают время жизни объекта до (в планах) С++ 20. Таким образом, вопрос тривиален, если только мы не предполагаем нетривиальный деструктор или что-то более сильное, например, delete.

В этом случае упорядочение в С++ 17 не помогает: вызов (не доступ к члену класса) использует указатель на объект (для инициализации this) в нарушение правил для устаревшие указатели.

Примечание: если бы только один порядок был неопределенным, то был бы "неопределенный порядок" до С++ 17: если любая из возможностей для неопределенного поведения - это неопределенное поведение, поведение не определено. (Как бы вы сказали, что был выбран четко определенный вариант? Неопределенный мог бы подражать ему, а затем выпустить носовых демонов.)

Ответ 2

Постфиксное выражение a->f секвенируется перед вычислением любых аргументов (которые неопределенно секвенированы относительно друг друга). (См. [Expr.call])

Оценка аргументов упорядочена перед телом функции (даже встроенные функции, см. [Intro.execution])

Смысл в том, что вызов самой функции не является неопределенным поведением. Тем не менее, доступ к любым переменным-членам или вызов других функций-членов в пределах будет UB на [basic.life].

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

Ответ 3

Похоже, вы предполагаете, что a->f(0) имеет следующие шаги (в таком порядке для самого последнего стандарта C++, в некотором логическом порядке для предыдущих версий):

  • оценивая *a
  • вычисление a->f (так называемая связанная функция-член)
  • оценка 0
  • вызов связанной функции-члена a->f в списке аргументов (0)

Но у a->f нет ни значения, ни типа. По сути, это не вещь, бессмысленный синтаксический элемент, необходимый только потому, что грамматика разлагает доступ к элементу и вызов функции, даже при вызове функции-члена, который по определению объединяет доступ к элементу и вызов функции.

Поэтому вопрос о том, когда "TG47" "оценивается", является бессмысленным вопросом: не существует такого понятия, как отдельный шаг оценки для выражения a->f без значения и без типа.

Поэтому любые рассуждения, основанные на таких обсуждениях порядка оценки не-сущности, также являются недействительными и недействительными.

EDIT:

На самом деле это хуже, чем то, что я написал, выражение a->f имеет фальшивый "тип":

E1.E2 - это 'функция списка параметров типа cv, возвращающая T'.

Функция function-type-list cv - это даже не то, что было бы допустимым декларатором вне класса: нельзя иметь f() const в качестве декларатора, как в глобальном объявлении:

int ::f() const; // meaningless

И внутри класса f() const это не означает "функцию параметра-типа-списка =() с cv = const", это означает функцию-член (параметра-типа-списка =() с cv = const). Не существует надлежащего декларатора для правильной "функции списка параметров типа cv". Он может существовать только внутри класса; нет функции типа "список параметров типа cv, возвращающего T", который может быть объявлено или что могут иметь реальные вычислимые выражения.

Ответ 4

В дополнение к тому, что говорили другие:

a-> ~ А(); удалить;

Эта программа имеет утечку памяти, которая технически не является неопределенным поведением. Однако, если вы вызвали delete a;, чтобы предотвратить это - это должно было быть неопределенное поведение, потому что delete будет вызывать a->~A() во второй раз [Раздел 12.4/14].

a-> ~ А()

Иначе в действительности это так, как другие предлагали - компилятор генерирует машинный код в соответствии с A* a = malloc(sizeof(A)); a->A(); a->~A(); a->f(0);. Поскольку нет переменных-членов или виртуальных элементов, все три функции-члена пусты ({return;}) и ничего не делают. Указатель a даже указывает на правильную память. Он будет работать, но отладчик может жаловаться на утечку памяти.

Однако использование любых нестатических переменных-членов внутри f() могло бы быть неопределенным поведением, потому что вы обращаетесь к ним после того, как они (неявно) уничтожены сгенерированным компилятором ~A(). Это может привести к ошибке во время выполнения, если это будет что-то вроде std::string или std::vector.

удалить

Если вы замените a->~A() выражением, которое вместо этого вызвало delete a;, то я считаю, что это было бы неопределенным поведением, поскольку указатель a больше не действителен в этой точке.

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

новый (а) А

auto a = new A; new(a) A; само по себе является неопределенным поведением, потому что вы вызываете A() второй раз для той же памяти.

В этом случае вызов f() сам по себе был бы действительным, потому что a существует, но создание a дважды - это UB.

Он будет работать нормально, если A не содержит объектов с конструкторами, выделяющими память и тому подобное. В противном случае это может привести к утечкам памяти и т.д., Но f() получит доступ ко "второй" копии.

Ответ 5

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

#include <iostream>
#include <exception>

struct A {
    int x{5};
    void f(int){}
    int g() { std::cout << x << '\n'; return x; }
};

int main() {
    try {
        auto a = new A;
        a->f((a->~A(), a->g()));
    catch(const std::exception& e) {
        std::cerr << e.what();
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

Я использую Visual Studio 2017 CE с флагом языка компилятора, установленным на /std:c++latest, и моя версия IDE - 15.9.16, и я получаю следующий вывод на консоль и выход из программы:

консольный вывод

5

Вывод статуса выхода из IDE

The program '[4128] Test.exe' has exited with code 0 (0x0).

Так что, похоже, это определено в случае Visual Studio, я не уверен, как другие компиляторы будут относиться к этому. Деструктор вызывается, однако переменная a все еще находится в динамической памяти кучи.


Давайте попробуем еще одну небольшую модификацию:

#include <iostream>
#include <exception>

struct A {
    int x{5};
    void f(int){}
    int g(int y) { x+=y; std::cout << x << '\n'; return x; }
};

int main() {
    try {
        auto a = new A;
        a->f((a->~A(), a->g(3)));
    catch(const std::exception& e) {
        std::cerr << e.what();
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

консольный вывод

8

Вывод состояния выхода из среды IDE

The program '[4128] Test.exe' has exited with code 0 (0x0).

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

int main() {
    try {
        auto a = new A;
        a->f((a->~A(), a->g(3)));
        a->g(2);
    } catch( const std::exception& e ) {
        std::cerr << e.what();
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

консольный вывод

8
10

Вывод статуса выхода из IDE

The program '[4128] Test.exe' has exited with code 0 (0x0).

Здесь кажется, что a.x сохраняет свое значение после вызова a->~A(), так как new был вызван на A, а delete еще не был вызван.


Еще больше, если я удалю new и использую указатель стека вместо выделенной динамической памяти кучи:

int main() {
    try {
        A b;
        A* a = &b;    
        a->f((a->~A(), a->g(3)));
        a->g(2);
    } catch( const std::exception& e ) {
        std::cerr << e.what();
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

Я все еще получаю:

консольный вывод

8
10

Вывод состояния выхода из среды IDE


  Когда я изменяю настройку флага языка компилятора с /c:std:c++latest на /std:c++17, я получаю точно такие же результаты.

То, что я вижу в Visual Studio, кажется, что оно четко определено без создания UB в контексте того, что я показал. Однако с точки зрения языка, когда речь идет о стандарте, я бы не стал полагаться и на этот тип кода. Вышеприведенное также не учитывает, когда класс имеет внутренние указатели, как автоматическое хранение в стеке, так и динамическое выделение кучи, и если конструктор вызывает new для этих внутренних объектов, а деструктор вызывает delete для них.

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