Продление времени жизни временных объектов тема широкая. И в этой серии заметок она встречалась не раз. Ведь работает эта особенность довольно в ограниченном числе случаев и чаще всего можно получить висячую ссылку. Однако в этой заметке я хочу остановиться на менее очевидном случае, с не совсем ожидаемыми последствиями.
В C++ при первом присваивании временного объекта const lvalue
или rvalue
ссылке, время жизни этого объекта расширяется до времени жизни ссылки.
std::string get_string();
void run(const std::string&);
int main() {
const std::string& s1 = get_string();
run(s1); // ok, ссылка валидна
std::string&& s2 = get_string();
run(s2); // ok, ccылка валидна
// но
std::string&& s3 = std::move(get_string()); // ссылка уже не валидна!
// первое присваивание -- ссылка в аргументе std::move,
// ее время жизни ограничено телом move
// аналогично для любой другой функции, принимающей и возвращающей ссылку
// (std::move тут взят только для примера)
}
Чуть менее очевидная особенность: не только лишь ссылка на сам временный объект дает такой эффект, но и на любой его подобъект!
#include <iostream>
#include <string>
#include <vector>
struct User {
std::string name;
std::vector<int> tokens;
};
User get_user() {
return {
"Dmitry",
{1,2,3,4,5}
};
}
int main() {
std::string&& name = get_user().name;
// some hacky address arithmetics: User is alive, we can access data in it!
// build with -fsanitize=address to ensure!
auto& v = *(std::vector<int>*)((char*)(&name) + sizeof(std::string));
for (int x : v) {
std::cout << x;
}
}
Код выше выведет содержимое вектора tokens
из объекта User
. И в этом даже нет ничего противозаконного: никаких dangling references и use-after-free. Ссылка на поле обеспечивает продление жизни всего объекта. И это может быть ссылка на сколь угодно вложенное поле:
struct Name {
std::string name;
};
struct User {
Name name;
std::vector<int> tokens;
};
...
int main() {
std::string&& name = get_user().name.name;
...
}
И вложенные поля даже могут быть массивами! (C arrays, с std::array работать не будет из-за перегрузки operator[]!)
struct Name {
std::string name;
};
struct User {
Name name[2];
std::vector<int> tokens;
};
User get_user() {
return {
{ "Dmitry", "Dmitry" },
{1,2,3,4,5}
};
}
int main() {
std::string&& name = get_user().name[1].name;
...
}
Здорово! Но пытливый читатель уже, наверное, догадался, в чем проблема: Мы берем ссылку на только одно поле, и, наверное, собираемся работать только с ним одним, а объект остается жить целиком... А что если остальные его поля держат выделенную память? А что если нам критически важно, чтоб у них был вызван деструктор?
Для наглядной демонстрации проблемы я приведу пример, внезапно, не на C++, а на Rust, поскольку там необходимый для создания неприятностей тип есть из стандартной коробки, ровно как и изысканно сломанная синтаксическая конструкция:
use parking_lot::Mutex;
#[derive(Default, Debug)]
struct State {
value: u64,
}
impl State {
fn is_even(&self) -> bool {
self.value % 2 == 0
}
fn increment(&mut self) {
self.value += 1
}
}
fn main() {
let s: Mutex<State> = Default::default();
match s.lock().is_even() {
true => {
s.lock().increment(); // oops, double lock!
}
false => {
println!("wasn't even");
}
}
dbg!(&s.lock());
}
Этот пример уходит в дедлок: временный объект LockGuard
в операторе match
остается жив по совершенной нелепости! Подробнее можно почитать тут. А мы же вернемся к C++
Если мы по какой-то причине решили последовать примеру Rust и сделать mutex
явно ассоциированный с данными (как и должно быть в 95% случаев), мы получаем такую же проблему при неаккуратном использовании ссылок:
template <class T>
struct Mutex {
T data;
std::mutex _mutex;
explicit Mutex(T data) : data {data} {}
auto lock() {
struct LockGuard {
public:
LockGuard(T& data, std::unique_lock<std::mutex>&& guard) : data(data), guard(std::move(guard)) {}
std::reference_wrapper<T> data;
private:
std::unique_lock<std::mutex> guard;
};
return LockGuard(this->data, std::unique_lock{_mutex});
}
};
int main() {
Mutex<int> m {15};
// double lock (deadlock, ub) due to LockGuard lifetime extension, remove && and it will be fine
auto&& data = m.lock().data;
std::cout << data.get() << "\n";
auto&& data2 = m.lock().data;
std::cout << data2.get() << "\n";
}
"Ну тут же сам себе злой буратино" -- скажут опытные защитники C++: "Зачем ссылка, если там и так reference_wrapper". И будут, разумеется правы. Но не переживайте. В C++23 теперь есть такая же сломанная конструкция как и match
в Rust. И это... range-based-for
!
Удивительнейшим образом изменения в стандарте, направленные на то чтоб починить висячую ссылку в конструкции
for (auto item : get_object().get_container()) { ... }
Теперь позволяют вляпаться в точно такой же дедлок как в Rust
template <class T>
struct Mutex {
T data;
std::mutex _mutex;
explicit Mutex(T data) : data {data} {}
auto lock() {
struct LockGuard {
public:
LockGuard(T& data, std::unique_lock<std::mutex>&& guard) : data(data), guard(std::move(guard)) {}
std::reference_wrapper<T> data;
T& get() const {
return data.get();
}
private:
std::unique_lock<std::mutex> guard;
};
return LockGuard(this->data, std::unique_lock{_mutex});
}
};
struct User {
std::vector<int> _tokens;
std::vector<int> tokens() const {
return this->_tokens;
}
};
int main() {
Mutex<User> m { { {1,2,3, 4,5} } };
for (auto token: m.lock().get().tokens()) {
std::cout << token << "\n";
m.lock(); // deadlock C++23
}
}
Самое замечательно в этом всем то, что в данный момент это "исправленное" поведение еще не реализовано в основных компиляторах. Но скоро, лет через пять, когда вы их обновите... Вас может ждать много удивительных открытий!