80% случаев неопределенного поведения в C++ связаны с ними.
Объект жил на стеке и умер. Или объект жил в куче и умер. Разница по сути не очень большая: обобщенный сценарий воспроизведения ошибки один и тот же — где-то остались указатель или ссылка на уже мертвый объект. А потом этой ссылкой (или указателем) воспользовались, чтобы обратиться к мертвому объекту. Такой спиритический сеанс заканчивается неопределенным поведением. Если повезет — будет ошибка сегментации с возможностью узнать, кто именно обратился.
Но все же между первым объектом со стека или первым объектом из кучи есть разница в возможности обнаружения методами динамического анализа: Для инструментации стека санитайзерами вообще говоря нужно перекомпилировать программу. Для инструментации кучи — можно подменить библиотеку с аллокатором.
Конечно, в жизни почти никто и никогда явно не пишет некорректный код вида
int main() {
int* p = nullptr;
{
int x = 5;
p = &x;
}
return *p;
}
Но проблема в том, что подобный код в языке С++ может быть ловко замаскирован под слоем абстракций из классов и функций.
Простой пример:
int main() {
int x = 11;
auto&& min_x_10 = std::min(x, 10);
return min_x_10;
}
В этом коде неопределенное поведение из-за висячей ссылки.
Проблема в том, что std::min
объявлен как
template<class T> const T& min(const T& a, const T& b);
Число 10
является временным объектом (prvalue), который умирает сразу же по выходе из функции std::min
.
В C++ разрешено присваивать временные объекты константным ссылкам. В таком случае константная ссылка продлевает временному объекту жизнь (объект «материализуется») и живет до выхода ссылки из области видимости. Дальнейшие присваивания константным ссылкам эффекта продлевания времени жизни не имеют.
Любой код, возвращающий из функции или метода ссылку или сырой указатель, является потенциальным источником проблем где угодно. Код, который только принимает аргументы по ссылке и никуда эти ссылки не сохраняет, также может быть источником проблем, но в куда более неочевидных ситуациях.
template <class T>
void append_n_copies(std::vector<T>* elements, const T& x, int N) {
for (int i = 0; i < N; ++i) {
elements->push_back(x);
}
}
void foo() {
std::vector<int> v; v.push_back(10);
...
append_n_copies(&v, v.front(), 5); // будет UB при реаллокации вектора!
}
У такого кода есть все шансы появиться в реальном проекте и доставить множество неприятностей.
В некритичных к производительности участках кода лучше использовать передачу по значению и перемещение вместо передачи по ссылке. Это, увы, также не снимает всех проблем, но ошибка в программе будет явно локализована в точке вызова функции, а не размазана по ее телу.
template <class T>
std::vector<T> append_n_copies(std::vector<T> elements, T x, int N) {
for (int i = 0; i < N; ++i) {
elements.push_back(x);
}
return elements; // implicit move
}
void foo() {
std::vector<int> v; v.push_back(10);
...
// v = append_n_copies(std::move(v), v.front(), 5);
// UB, use-after-move, порядок вычисления аргументов неопределен:
// v.front() может быть вызван на пустом векторе
auto el = v.front();
v = append_n_copies(std::move(v), std::move(el), 5);
}
Если нужно работать со ссылками, стоит озаботиться их безопасностью.
Например, можно использовать std::reference_wrapper
, которому нельзя присваивать временные объекты.
#include <utility>
template <class T>
std::reference_wrapper<const T> safe_min(std::reference_wrapper<const T> a,
std::reference_wrapper<const T> b){
return std::min(a, b);
}
int main() {
const int x = 11;
auto&& y = safe_min<int>(x, 11); // compilation error
}
Или, с помощью forwarding references проанализировать категорию (rvalue/lvlaue) переданного аргумента и решить, что с ним делать. На С++20 это выглядит так:
#include <type_traits>
template <class T1, class T2>
requires std::is_same_v<std::decay_t<T1>,
std::decay_t<T2>> // std::min требует одинаковых типов
decltype(auto) // выводим тип без отбрасывания ссылок
safe_min(T1&& a, T2&& b) { // forwarding reference на каждый аргумент.
if constexpr (std::is_lvalue_reference_v<decltype(a)> &&
std::is_lvalue_reference_v<decltype(b)>) {
// оба аргумента были lvalue — можно безопасно вернуть ссылку
return std::min(a, b);
} else {
// один из аргументов — временный объект.
// возвращаем по значению.
// для этого делаем копию
auto temp = std::min(a,b); // auto&& нельзя!
// иначе return выведет ссылку
return temp;
}
}
Конкретно для функций std::min
и std::max
в стандартной библиотеке есть безопасные версии, принимающие аргументы по значению и также возвращающие результат по значению.
Более того, они «поддерживают» более двух аргументов.
const int x = 11;
const int y = 20;
auto&& y = std::min({x, 10, 15, y}); // OK
Может показаться, что проблема возврата ссылок касается только const
ссылок. С неконстантными ссылками никаких паразитных продлений жизни нет, и все должно быть хорошо. Однако, это не совсем так.
Все вышеописанное рассматривало только свободные функции и, что то же самое, статические методы классов.
Но с методами классов возврат ссылок — обычное дело. И проблемы с ними те же, но менее явные. Неявность связана с передачей указателя this
на текущий объект.
Так, например, безопасная реализация условного Builder с поддержкой вызовов методов по цепочке оказывается весьма нетривиальной.
class VectorBuilder {
std::vector<int> v;
public:
VectorBuilder& Append(int x) {
v.push_back(x);
return *this;
}
const std::vector<int>& GetVector() { return v; }
};
int main() {
auto&& v = VectorBuilder{}
.Append(1)
.Append(2)
.Append(3)
.GetVector(); // dangling reference
}
Проблема опять в умирающем объекте, вернувшем ссылку на свое содержимое.
Если мы перегрузим лишь GetVector
, чтобы различать lvalue и rvalue, проблема не исчезнет:
class VectorBuilder {
...
const std::vector<int>& GetVector() & {
std::cout << "As Lvalue\n";
return v;
}
std::vector<int> GetVector() && {
std::cout << "As Rvalue\n";
return std::move(v);
}
};
Мы получим сообщение «As Lvalue». Цепочка Append
неявно превратила безымянный временный объект в не совсем временный.
Append
также нужно перегрузить для разбора случаев rvalue и lvalue:
class VectorBuilder {
...
VectorBuilder& Append(int x) & {
v.push_back(x);
return *this;
}
VectorBuilder&& Append(int x) && {
v.push_back(x);
return std::move(*this);
}
};
Мы справились с висячей ссылкой на содержимое вектора.
Однако, если мы захотим написать так
auto&& builder = VectorBuilder{}.Append(1).Append(2).Append(3);
Опять получим висячую ссылку, но уже на сам объект VectorBuilder
. Добавленная перегрузка Append
тут ни при чем — неявный this
и в исходном случае успевал прибиваться к временному объекту, и единоразово продлевать ему жизнь.
Чтобы этого избежать, нам нужно:
Либо настраивать линтер, запрещающий использовать auto&&
и const auto&
c этим классом в правой части.
Либо жертвовать производительностью, и в rvalue версии Append
возвращать по значению (+ move) — при большом количестве примитивных, всегда копируемых, объектов внутри, просадка будет заметной.
Либо в принципе запретить использовать VectorBuilder
в rvalue контексте:
class VectorBuilder {
...
auto Append() && = delete;
}
Но тогда строить цепочки, начиная с безымянного временного объекта, будет нельзя.
Также, не стоит никогда играть с цепочками операций op=
(+=
, -=
, /=
) над временными объектами. Для них редко когда обрабатывают rvalue случай:
struct Min {
int x;
Min& operator += (const Min& other) {
x = std::min(x, other.x);
return *this;
};
};
int main() {
auto&& m = (Min{5} += Min {10});
return m.x; // dangling reference
}
Или с использованием типов стандартной библиотеки:
int main() {
using namespace std::literals;
auto&& m = "hello"s += "world";
std::cout << m; // dangling reference
}