Вызов нестатической функции-члена вне времени жизни объекта в C ++ 17 - PullRequest
15 голосов
/ 23 сентября 2019

Имеет ли следующая программа неопределенное поведение в C ++ 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));
}

C ++ 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 упорядочены после первого соответствующего оператора, в то время как в моем первоначальном примере этот порядок обратный.Мой вопрос состоит в том, допускает ли это изменение ответы на изменения?


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


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

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

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

[...]

, тогда как текущий черновик говорит:

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

[...]

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

[...]

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


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


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

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


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

Ответы [ 5 ]

6 голосов
/ 23 сентября 2019

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

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

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

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

3 голосов
/ 27 сентября 2019

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

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

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

2 голосов
/ 24 сентября 2019

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

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

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

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

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

РЕДАКТИРОВАТЬ:

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

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

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

int ::f() const; // meaningless

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

0 голосов
/ 24 сентября 2019

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

#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).

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

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 для них.

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

0 голосов
/ 24 сентября 2019

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

a-> ~ A ();delete;

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

a-> ~ A ()

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

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

delete a

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

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

new (a) A

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

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

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...