Постоянный член и оператор присваивания. Как избежать неопределенного поведения?
Я ответил на вопрос о std::vector объектов и const-правильности и получил незаслуженное понижение рейтинга и комментарий о неопределенном поведении. Я не согласен, и поэтому у меня есть вопрос.
Рассмотрим класс с константным членом:
class A {
public:
const int c; // must not be modified!
A(int c) : c(c) {}
A(const A& copy) : c(copy.c) { }
// No assignment operator
};
Я хочу иметь оператор присваивания, но я не хочу использовать const_cast
как в следующем коде из одного из ответов:
A& operator=(const A& assign)
{
*const_cast<int*> (&c)= assign.c; // very very bad, IMHO, it is UB
return *this;
}
Мое решение
A& operator=(const A& right)
{
if (this == &right) return *this;
this->~A()
new (this) A(right);
return *this;
}
Есть ли у меня неопределенное поведение?
Пожалуйста, ваше решение без UB.
6 ответов
Ваш код вызывает неопределенное поведение.
Не просто "неопределено, если A используется в качестве базового класса и того, или другого". На самом деле не определено, всегда. return *this
уже UB, потому что this
не гарантируется ссылка на новый объект.
В частности, рассмотрим 3.8/7:
Если по истечении времени жизни объекта и до повторного использования или освобождения хранилища, которое занимал объект, в месте хранения, которое занимал исходный объект, создается новый объект, указатель, указывающий на исходный объект, ссылка, которая ссылка на исходный объект, или имя исходного объекта будет автоматически ссылаться на новый объект и, как только начнется время жизни нового объекта, может использоваться для управления новым объектом, если:
...
- тип исходного объекта не является константно-квалифицированным, и, если тип класса, не содержит какого-либо нестатического члена данных, тип которого является константно-квалифицированным или ссылочным типом,
Теперь, "после того, как закончился срок службы объекта и до того, как хранилище, которое занимал объект, используется повторно или освобождено, новый объект создается в месте хранения, которое занимал исходный объект", это именно то, что вы делаете.
Ваш объект имеет тип класса, и он содержит нестатический член данных, тип которого является константным. Поэтому после запуска вашего оператора присваивания указатели, ссылки и имена, ссылающиеся на старый объект, не обязательно будут ссылаться на новый объект и использоваться для манипулирования им.
В качестве конкретного примера того, что может пойти не так, рассмотрим:
A x(1);
B y(2);
std::cout << x.c << "\n";
x = y;
std::cout << x.c << "\n";
Ожидайте этот вывод?
1
2
Неправильно! Возможно, вы получите такой вывод, но причина, по которой члены const являются исключением из правила, изложенного в 3.8/7, заключается в том, что компилятор может обрабатывать x.c
как постоянный объект, который он утверждает, что. Другими словами, компилятору разрешено обрабатывать этот код так, как если бы он был:
A x(1);
B y(2);
int tmp = x.c
std::cout << tmp << "\n";
x = y;
std::cout << tmp << "\n";
Потому что (неформально) объекты const не меняют своих значений. Потенциальная ценность этой гарантии при оптимизации кода с использованием константных объектов должна быть очевидной. Ибо есть какой-либо способ изменить x.c
без обращения к UB эта гарантия должна быть удалена. Таким образом, пока стандартные авторы выполнили свою работу без ошибок, нет способа делать то, что вы хотите.
[*] На самом деле у меня есть сомнения по поводу использования this
в качестве аргумента для размещения нового - возможно, вы должны были скопировать его в void*
во-первых, и использовал это. Но меня не беспокоит, является ли это конкретно UB, поскольку это не спасет функцию в целом.
Первое: когда вы делаете элемент данных const
Вы говорите компилятору и всему миру, что этот элемент данных никогда не меняется. Конечно, тогда вы не можете назначить его, и вы, конечно, не должны обманывать компилятор, чтобы он принимал код, который делает это, независимо от того, насколько хитрый трюк.
Вы можете иметь const
элемент данных или оператор присваивания, назначающий всем элементам данных. Вы не можете иметь оба.
Что касается вашего "решения" проблемы:
Я предполагаю, что вызов деструктора для объекта в функции-члене, вызванной для этих объектов, сразу вызовет UB. Вызов конструктора для неинициализированных необработанных данных для создания объекта внутри функции-члена, которая была вызвана для объекта, который находился там, где теперь конструктор вызывается для необработанных данных... для меня это тоже очень похоже на UB. (Черт, только из-за того, что это произнесено, мои ноги на ногах скручиваются.) И нет, у меня нет главы и стиха стандарта для этого. Я ненавижу читать стандарт. Я думаю, что не могу вынести его метр.
Однако, если оставить в стороне технические детали, я допускаю, что вы можете использовать свое "решение" практически на каждой платформе, если код остается таким же простым, как в вашем примере. Тем не менее, это не делает это хорошим решением. На самом деле, я бы сказал, что это даже неприемлемое решение, потому что код IME никогда не бывает таким простым. С годами он будет расширяться, изменяться, видоизменяться и искажаться, а затем он будет молча терпеть неудачу и потребовать ошеломляющей 36-часовой смены отладки, чтобы найти проблему. Я не знаю о вас, но всякий раз, когда я нахожу фрагмент кода, который отвечает за 36 часов отладки, я хочу задушить несчастного тупого глупца, который сделал это со мной.
Херб Саттер, в своем GotW # 23, рассекает эту идею по частям и в заключение приходит к выводу, что она " полна ловушек, часто ошибочна и делает жизнь адом для авторов производных классов... никогда не используйте хитрость" реализации копирования с точки зрения создания копии с использованием явного деструктора с последующим размещением нового, хотя этот прием появляется каждые три месяца в группах новостей " (подчеркну мое).
Как вы можете присвоить A, если у него есть постоянный член? Вы пытаетесь сделать что-то, что принципиально невозможно. Ваше решение не имеет нового поведения по сравнению с оригиналом, которое не обязательно UB, но ваше, безусловно, есть.
Простой факт заключается в том, что вы меняете постоянный член. Вам нужно либо отменить свой член, либо отказаться от оператора присваивания. Нет решения вашей проблемы - это полное противоречие.
Изменить для большей ясности:
Const cast не всегда вводит неопределенное поведение. Вы, однако, наверняка сделали. Помимо всего прочего, не определено не вызывать все деструкторы - и вы даже не назвали правильный - до того, как поместили в него, если не знали наверняка, что T - это класс POD. Кроме того, существует множество неопределенных форм поведения, связанных с различными формами наследования.
Вы вызываете неопределенное поведение, и вы можете избежать этого, не пытаясь назначить объект const.
Во-первых, вся мотивация вашего (можно сказать довольно гениального) использования "нового размещения" как средства реализации оператора присваивания,
operator=()
, как это вызвано этим вопросом ( std::vector объектов и константная правильность), теперь обнуляется. Начиная с C++11, этот код вопроса теперь не содержит ошибок. Смотрите мой ответ здесь.
Во-вторых, C++11
emplace()
функции теперь делают в значительной степени то же самое, что и ваше использование размещения new, за исключением того, что теперь сами компиляторы фактически гарантируют, что их поведение будет четко определенным в соответствии со стандартом C++.
В-третьих, когда принятый ответ гласит:
потому как
this
не гарантируется ссылка на новый объект
Интересно, это потому, что значение, содержащееся в
this
переменная может быть изменена путем размещения новой операции копирования-построения, НЕ потому что все, что использует этот экземпляр класса, может сохранить его кэшированное значение со старыми данными экземпляра, а не читать новое значение экземпляра объекта из памяти. Если первое, мне кажется, вы могли бы гарантировать
this
правильно внутри функции оператора присваивания, используя временную копию
this
указатель, например:
// Custom-defined assignment operator
A& operator=(const A& right)
{
if (this == &right) return *this;
// manually call the destructor of the old left-side object
// (`this`) in the assignment operation to clean it up
this->~A();
// Now back up `this` in case it gets corrupted inside this function call
// only during the placement new copy-construction operation which
// overwrites this objct:
void * thisBak = this;
// use "placement new" syntax to copy-construct a new `A`
// object from `right` into left (at address `this`)
new (this) A(right);
// Note: we cannot write to or re-assign `this`.
// See here: https://stackru.com/a/18227566/4561887
// Return using our backup copy of `this` now
return *thisBak;
}
Но, если это связано с кешированием объекта, а не перечитыванием при каждом его использовании, мне интересно,
volatile
решит это! то есть: использовать
volatile const int c;
как член класса вместо
const int c;
.
В-четвертых, в оставшейся части своего ответа я сосредоточусь на использовании
volatile
применительно к членам класса, чтобы увидеть, может ли это решить второй из этих двух потенциальных случаев неопределенного поведения:
Потенциальный UB в вашем собственном решении:
// Custom-defined assignment operator A& operator=(const A& right) { if (this == &right) return *this; // manually call the destructor of the old left-side object // (`this`) in the assignment operation to clean it up this->~A(); // use "placement new" syntax to copy-construct a new `A` // object from `right` into left (at address `this`) new (this) A(right); return *this; }
Упомянутый вами потенциальный UB может существовать в другом решении.
// (your words, not mine): "very very bad, IMHO, it is // undefined behavior" *const_cast<int*> (&c)= assign.c;
Хотя думаю возможно добавление
volatile
может исправить оба приведенных выше случая, в остальной части этого ответа я сосредоточен на втором случае чуть выше.
tldr;
Мне кажется, что это (в частности, второй случай чуть выше) становится допустимым и четко определенным поведением по стандарту, если вы добавите
volatile
и сделайте переменную члена класса
volatile const int c;
вместо просто
const int c;
. Не могу сказать, что это отличная идея, но я думаю, что выбросить
const
и писать в
c
затем становится четко определенным поведением и совершенно достоверным. В противном случае, его поведение не определено только потому, что читает из
c
могут быть кэшированы и / или оптимизированы, так как это только
const
, а не также
volatile
.
Прочтите ниже для получения более подробной информации и обоснования, в том числе взгляните на некоторые примеры и небольшую сборку.
константный член и оператор присваивания. Как избежать неопределенного поведения?
Писать в
const
члены - это только неопределенное поведение...
... потому что компилятор может оптимизировать дальнейшее чтение переменной, поскольку она
const
. Другими словами, даже если вы правильно обновили значение, содержащееся по заданному адресу в памяти, компилятор может сказать коду просто слить все, что было последним в регистре, содержащем значение, которое он впервые прочитал, вместо того, чтобы возвращаться в память. адрес и фактически проверяет новое значение каждый раз, когда вы читаете эту переменную.
Итак, это:
// class member variable:
const int c;
// anywhere
*const_cast<int*>(&c) = assign.c;
вероятно, это неопределенное поведение. Это может работать в некоторых случаях, но не в других, в некоторых компиляторах, но не в других, или в некоторых версиях компиляторов, но не в других. Мы не можем полагаться на его предсказуемое поведение, потому что язык не определяет, что должно происходить каждый раз, когда мы устанавливаем переменную как
const
а затем писать и читать из него.
Вот эта программа, например (см. Здесь: https://godbolt.org/z/EfPPba):
#include <cstdio>
int main() {
const int i = 5;
*(int*)(&i) = 8;
printf("%i\n", i);
return 0;
}
печатает
5
(хотя мы хотели напечатать
8
) и производит эту сборку в
main
. (Учтите, что я не специалист по сборке). Я отметил
printf
линий. Вы можете видеть это, хотя
8
записывается в это место (mov DWORD PTR [rax], 8
),
printf
строки НЕ считывают это новое значение. Они зачитывают ранее сохраненные
5
потому что они не ожидают, что это изменится, хотя это и произошло. Поведение не определено, поэтому чтение в этом случае не выполняется.
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 5
lea rax, [rbp-4]
mov DWORD PTR [rax], 8
// printf lines
mov esi, 5
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
mov eax, 0
leave
ret
Писать в
volatile const
переменные, однако, не являются неопределенным поведением...
...потому как
volatile
сообщает компилятору, что лучше читать содержимое в фактической ячейке памяти при каждом чтении этой переменной, поскольку оно может измениться в любой момент!
Вы можете подумать: "Есть ли в этом вообще смысл?" (иметь
volatile const
переменная. Я имею в виду: "что может изменить
const
переменная, чтобы нам нужно было ее отметить
volatile
!?) Ответ: "ну да! В этом есть смысл!" На микроконтроллерах и других встраиваемых устройствах с низкоуровневой отображенной памятью некоторые регистры, которые могут измениться в любой момент из-за базового оборудования, доступны только для чтения. Чтобы пометить их как доступные только для чтения в C или C++, мы делаем их
const
, но чтобы компилятор лучше знал, что он действительно читает память по своему адресу каждый раз, когда мы читаем переменную, вместо того, чтобы полагаться на оптимизацию, которая сохраняет ранее кэшированные значения, мы также помечаем их как
volatile
. Итак, чтобы отметить адрес
0xF000
как доступный только для чтения 8-битный регистр с именем
REG1
, мы бы определили это где-нибудь в заголовочном файле:
// define a read-only 8-bit register
#define REG1 (*(volatile const uint8_t*)(0xF000))
Теперь мы можем читать ее по своему усмотрению, и каждый раз, когда мы просим код прочитать переменную, он будет. Это четко определенное поведение. Теперь мы можем сделать что-то подобное, и этот код НЕ будет оптимизирован, потому что компилятор знает, что это значение регистра действительно может измениться в любой момент времени, поскольку оно
volatile
:
while (REG1 == 0x12)
{
// busy wait until REG1 gets changed to a new value
}
И, чтобы отметить
REG2
как 8-битный регистр чтения / записи, мы, конечно, просто удалим
const
. Однако в обоих случаях
volatile
является обязательным, поскольку значения могут измениться в любой момент аппаратным обеспечением, поэтому компилятору лучше не делать никаких предположений об этих переменных или пытаться кэшировать их значения и полагаться на кэшированные показания.
// define a read/write 8-bit register
#define REG2 (*(volatile uint8_t*)(0xF001))
Следовательно, следующее поведение не является неопределенным! Насколько я могу судить, это очень четко определенное поведение:
// class member variable:
volatile const int c;
// anywhere
*const_cast<int*>(&c) = assign.c;
Хотя переменная
const
мы можем отбросить
const
и напишите в него, и компилятор будет уважать это и фактически напишет в него. И теперь, когда переменная также отмечена как
volatile
, компилятор будет читать его каждый раз и уважать его, как и чтение
REG1
или же
REG2
выше.
Таким образом, эта программа теперь, когда мы добавили
volatile
(см. здесь: https://godbolt.org/z/6K8dcG):
#include <cstdio>
int main() {
volatile const int i = 5;
*(int*)(&i) = 8;
printf("%i\n", i);
return 0;
}
печатает
8
, что теперь правильно, и производит эту сборку в
main
. Опять же, я отметил
printf
линий. Обратите внимание на новые и другие линии, которые я также отметил! Это единственные изменения в выводе сборки! Все остальные строки в точности идентичны. Новая строка, отмеченная ниже, гаснет и фактически считывает новое значение переменной и сохраняет его в регистре.
eax
. Затем при подготовке к печати вместо перемещения жестко запрограммированного
5
в регистр
esi
, как и раньше, перемещает содержимое регистра
eax
, который только что прочитан и теперь содержит
8
, в регистр
esi
. Решено! Добавление
volatile
починил это!
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 5
lea rax, [rbp-4]
mov DWORD PTR [rax], 8
// printf lines
mov eax, DWORD PTR [rbp-4] // NEW!
mov esi, eax // DIFFERENT! Was `mov esi, 5`
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
mov eax, 0
leave
ret
Вот более крупная демонстрация (запустите ее онлайн: https://onlinegdb.com/HyU6fyCNv). Вы можете видеть, что мы можем писать в переменную, приводя ее к неконстантной ссылке ИЛИ неконстантному указателю.
Во всех случаях (приведение как к неконстантным ссылкам, так и к неконстантным указателям для изменения значения const) мы можем использовать приведение в стиле C++ ИЛИ приведение в стиле C.
В простом примере выше я проверил, что во всех четырех случаях (даже используя приведение в стиле C для приведения к ссылке:
(int&)(i) = 8;
, как ни странно, поскольку у C нет ссылок:)) вывод сборки был таким же.
#include <stdio.h>
int main()
{
printf("Hello World\n");
// This does NOT work!
const int i1 = 5;
printf("%d\n", i1);
*const_cast<int*>(&i1) = 6;
printf("%d\n\n", i1); // output is 5, when we want it to be 6!
// BUT, if you make the `const` variable also `volatile`, then it *does* work! (just like we do
// for writing to microcontroller registers--making them `volatile` too). The compiler is making
// assumptions about that memory address when we make it just `const`, but once you make it
// `volatile const`, those assumptions go away and it has to actually read that memory address
// each time you ask it for the value of `i`, since `volatile` tells it that the value at that
// address could change at any time, thereby making this work.
// Reference casting: WORKS! (since the `const` variable is now `volatile` too)
volatile const int i2 = 5;
printf("%d\n", i2);
const_cast<int&>(i2) = 7;
// So, the output of this is 7:
printf("%d\n\n", i2);
// C-style reference cast (oddly enough, since C doesn't have references :))
volatile const int i3 = 5;
printf("%d\n", i3);
(int&)(i3) = 8;
printf("%d\n\n", i3);
// It works just fine with pointer casting too instead of reference casting, ex:
volatile const int i4 = 5;
printf("%d\n", i4);
*(const_cast<int*>(&i4)) = 9;
printf("%d\n\n", i4);
// or C-style:
volatile const int i5 = 5;
printf("%d\n", i5);
*(int*)(&i5) = 10;
printf("%d\n\n", i5);
return 0;
}
Пример вывода:
Hello World
5
5
5
7
5
8
5
9
5
10
Ноты:
- Я также заметил, что это работает при изменении
const
ученики, даже если они НЕvolatile
. См. Мою программу "std_optional_copy_test"! Пример: https://onlinegdb.com/HkyNyTt4D. Однако это, вероятно, неопределенное поведение. Чтобы сделать его четко определенным, сделайте переменную-членvolatile const
вместо простоconst
. - Причина, по которой вам не нужно использовать
volatile const int
кvolatile int
(например: почему простоint
ссылка илиint
указатель) работает нормально, потому чтоvolatile
влияет на чтение переменной, а НЕ на запись переменной. Итак, пока мы читаем переменную с помощью средств volatile-переменной, что мы и делаем, наши чтения гарантированно не будут оптимизированы. Вот что дает нам четко определенное поведение. Запись всегда работала - даже если переменная не былаvolatile
.
Refences:
- [мой собственный ответ] Какое использование используется для "нового размещения"?
- Руководство по сборке x86
- Измените указатель this объекта, чтобы он указывал на другой объект
- Выводы Compiler Explorer со сборкой из godbolt.org:
- Здесь: https://godbolt.org/z/EfPPba
- А здесь: https://godbolt.org/z/6K8dcG
- [мой ответ] Доступ к GPIO на уровне регистров на микроконтроллерах STM32: Программирование STM32 как STM8(GPIO на уровне регистров)
Согласно более новой стандартной черновой версии C++ N4861, похоже, больше не поведение undefined (ссылка):
Если после того, как время жизни объекта закончилось и до того, как хранилище, которое занимал объект, будет повторно использовано или освобождено, новый объект создается в том месте хранения, которое занимал исходный объект, указатель, указывающий на исходный объект, ссылка, которая относится к исходному объекту, или имя исходного объекта будет автоматически ссылаться на новый объект и, как только время жизни нового объекта начнется, может использоваться для управления новым объектом, если исходный объект является прозрачно заменяемым (см. ниже) новым объектом. Объект o1 можно прозрачно заменить объектом o2, если:
- память, которую занимает o2, в точности перекрывает память, которую занимает o1, и
- o1 и o2 имеют один и тот же тип (без учета квалификаторов cv верхнего уровня), и
- o1 не является полным константным объектом и
- ни o1, ни o2 не являются потенциально перекрывающимися подобъектами ([intro.object]), и
- либо o1 и o2 оба являются законченными объектами, либо o1 и o2 являются прямыми подобъектами объектов p1 и p2 соответственно, и p1 прозрачно заменяется на p2.
Здесь вы можете найти только "o1 не является полным объектом const" относительно const, что в данном случае верно. Но, конечно, вы должны следить за тем, чтобы не нарушались и все остальные условия.
Если вы определенно хотите иметь неизменяемый (но назначаемый) член, то без UB вы можете выложить такие вещи:
#include <iostream>
class ConstC
{
int c;
protected:
ConstC(int n): c(n) {}
int get() const { return c; }
};
class A: private ConstC
{
public:
A(int n): ConstC(n) {}
friend std::ostream& operator<< (std::ostream& os, const A& a)
{
return os << a.get();
}
};
int main()
{
A first(10);
A second(20);
std::cout << first << ' ' << second << '\n';
first = second;
std::cout << first << ' ' << second << '\n';
}
В отсутствие других (неconst
) члены, это не имеет никакого смысла, независимо от неопределенного поведения или нет.
A& operator=(const A& assign)
{
*const_cast<int*> (&c)= assign.c; // very very bad, IMHO, it is UB
return *this;
}
AFAIK, это не неопределенное поведение, происходящее здесь, потому что c
это не static const
экземпляр, или вы не можете вызвать оператор копирования-назначения. Тем не мение, const_cast
должен позвонить в колокольчик и сказать, что что-то не так. const_cast
был в первую очередь предназначен для работы без const
- правильные API, и здесь, похоже, это не так.
Также в следующем фрагменте:
A& operator=(const A& right)
{
if (this == &right) return *this;
this->~A()
new (this) A(right);
return *this;
}
У вас есть два основных риска, первый из которых уже был указан.
- При наличии как экземпляра производного класса
A
и виртуальный деструктор, это приведет только к частичной реконструкции исходного экземпляра. - Если конструктор позвонить в
new(this) A(right);
выдает исключение, ваш объект будет уничтожен дважды. В этом конкретном случае это не будет проблемой, но если вам случится провести значительную очистку, вы пожалеете об этом.
Изменить: если ваш класс имеет это const
член, который не считается "состоянием" в вашем объекте (т.е. это какой-то идентификатор, используемый для отслеживания экземпляров и не являющийся частью сравнений в operator==
и т.п.), тогда может иметь смысл следующее:
A& operator=(const A& assign)
{
// Copy all but `const` member `c`.
// ...
return *this;
}
Прочитайте эту ссылку:
http://www.informit.com/guides/content.aspx?g=cplusplus&seqNum=368
Особенно...
Этот трюк якобы предотвращает дублирование кода. Тем не менее, у него есть некоторые серьезные недостатки. Для работы деструктор C должен назначить NULLify каждому указателю, который он удалил, потому что последующий вызов конструктора копирования может снова удалить те же указатели, когда он переназначит новое значение для массивов символов.