Skip to content

Latest commit

 

History

History
316 lines (252 loc) · 15.2 KB

ternary_operator.md

File metadata and controls

316 lines (252 loc) · 15.2 KB

Lifetime extension в тернарном операторе

У этой истории довольно нестандартное начало для этой книги. Заметить странности и неожиданные ловушки во встроенном операторе C++ внезапно поспособствовала новая возможность в языке Rust. Последние несколько лет я много работаю с кодовыми базами на C++ и Rust, и на стыке между ними. А поскольку вполне естественно переносить ожидания из одного языка на другой, тем более когда они похожи, я решил проверить, а как же работает похожий код на С++.

Rust в версии 1.78 неожиданно расширил возможности по автоматическому продлению жизни временных объектов. Теперь в нем можно, например, написать так

let uri: &str = ...;
...
let updated_uri: &str = if !query.is_empty() {
    // Можно вернуть ссылку на временную строку!
    // ее время жизни будет автоматически продлено
    // Ранее этот код не проходил проверку заимствований
    // и не компилировался
    &format!("{uri}?{query}")
} else {
    uri
}

Раньше было сложнее совместить краткость и отсутствие лишней аллокации. Нужно либо писать довольно уродливо явно

let uri: &str = ...;
let updated_uri_tmp: String;
let updated_uri: &str = if !query.is_empty() {
    updated_uri_tmp = format!("{uri}?{query}");
    &updated_uri_tmp
} else {
    uri
};

либо прибегать к специальным типам

use std::borrow::Cow; // Clone-on-Write smart pointer
let updated_uri: Cow<str> = if !query.is_empty() {
    format!("{uri}?{query}").into() // wrap into Cow::Owned
} else {
    uri.into() // wrap into Cow::Borrowed
};

Особенно удобным становилось использование динамически полиморфных интерфейсов

let output: &mut dyn std::io::Write = match confg {
    StdOut => &mut std::io::stdout(),
    File { path } => &mut std::fs::File::create(path)?,
}

Вернемся теперь к C++. В нем, конечно, switсh не такой удобный и if - else не является выражением — не может возвращать значения. Но в C++ есть тернарный оператор, а вот он уже что-то возвращает.

Посмотрим на довольно распространенный сценарий: у нас есть некоторый key-value контейнер с неисзменяемой конфигурацией. Мы обращаемся к какому-то ключу, если он там есть -- отлично. Иначе -- используем значение по умолчанию.

using Map = std::map<int, std::string>;

// Мы сразу рассмотрим наиболее общий случай 
// и наиболее оптимальный случай:
// значение по умолчанию предоставляется функцией. 
// Так мы можем избежать его вычисления, если ключ есть в таблице
void test_default_getter(const Map& m, int key, auto default_getter) {
    std::cout << "try ternary ? const string& : function()\n";
    auto iter = m.find(key);
    
    // используем auto и universal reference, поскольку 
    // 1. Мы не знаем тип default_getter()
    // 2. Это работает
    // 3. Так рекомендуют гайдлайны -- можем еще const добавить
    auto&& value = iter != m.end() ? iter->second : default_getter();
    std::cout << "value=" << value << "\n";
    std::cout << "address=" << uintptr_t(value.data()) << "\n";
}

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

А теперь я предлагаю вам посмотреть на следующие 7 случаев и ответить, что именно произойдет

int main() {
    const Map m {
        {42, "Value in Table"}
    };
    std::cout << "data address in map: " << uintptr_t(m.at(42).data()) << "\n";
    using namespace std::literals;
    test_default_getter(m, 42, []{  return "default"sv; });
    test_default_getter(m, 42, [s = "default"s]() -> const std::string& {  return s; });
    test_default_getter(m, 42, [s = "default"s]() mutable -> std::string& {  return s; });
    test_default_getter(m, 42, [s = "default"s]() mutable -> std::string&& {  return std::move(s); });
    test_default_getter(m, 42, [s = "default"s]() -> const std::string&& {  return std::move(s); });
    test_default_getter(m, 42, []{  return "default"s; });
    test_default_getter(m, 42, []{ return "default"; });
}

Несмотря на все разумные ожидания, только в первых трех случаях мы действительно получим ссылку на строку в таблице. В оставшихся четырех мы неявно получим копию!

https://godbolt.org/z/dha9b8fY6

data address in map: 7201512
try ternary ? const string& : function()
value=Value in Table
address=7201512
try ternary ? const string& : function()
value=Value in Table
address=7201512
try ternary ? const string& : function()
value=Value in Table
address=7201512
try ternary ? const string& : function()
value=Value in Table
address=140737440180832
try ternary ? const string& : function()
value=Value in Table
address=140737440180832
try ternary ? const string& : function()
value=Value in Table
address=140737440180832
try ternary ? const string& : function()
value=Value in Table
address=140737440180832

У тернарного оператора в C++ совершенно удивительные правила вывода типа возвращаемого значения:

bool ? T&  : T&  -> T&
bool ? T&& : T&& -> T&&
bool ? T&  : T&& -> T
bool ? T&  : T   -> T
bool ? U   : T   -> U или T, что к чему приведется

Посмотрим на наши примеры:

test_default_getter(m, 42, []{  return "default"sv; });

const string& : string_view — первый неявно приводим ко второму. Взятие string_view от string происходит без копирования. Все отлично... И вроде безопасно.

А что если наша таблица с конфигурацией отпимизирована хранить string_view на части одной большой json-конфигурационной строки и ключа в ней нет?

using RefMap = std::map<int, std::string_view>;

void test_refmap_string(const RefMap& m, int key) {
    auto iter = m.find(key);
    auto&& value = iter != m.end() ? iter->second : std::format("value_for_key_{}_generated", key);
    std::cout << value << "\n";
}

int main() {
    const RefMap m1 {
        {42, "Value in Table"}
    };
    test_refmap_string(m1, 43);
}

Получаем классический use-after-free cо string-view

https://godbolt.org/z/xPhz7rTMb
D�q�kp�Ki%_generated

Неявные приведения типов всегда очень удобны для написания некорректных программ.

Продолжаем дальше с примерами. Второй и третий:

    test_default_getter(m, 42, [s = "default"s]() -> const std::string& {  return s; });
    test_default_getter(m, 42, [s = "default"s]() mutable -> std::string& {  return s; });

Вполне естественно работают как ожидалось -- без копирования. В обеих ветвях тернарного оператора окажутся lvalue ссылки, const не важен. Результатом будет lvalue ссылка.

Четвертый и пятый

test_default_getter(m, 42, [s = "default"s]() mutable -> std::string&& {  return std::move(s); });
    test_default_getter(m, 42, [s = "default"s]() -> const std::string&& {  return std::move(s); });

Дадут const std::string& : [const] std::string&& в тернарном операторе и, согласно его правилам вывода, должны вернуть std::string. То есть скопировать из левого или переместить из правого. По-другому быть не может.

Условно продлять время жизни и условно вызывать деструкторы C++, в отличие от Rust, не умеет. Если мы посмотрим снова на Rust-пример

let updated_uri: &str = if !query.is_empty() {
    // чтобы это работало в Rust, на стеке 
    // уже должно быть неявно зарезервировано место под объект
    // а также должен быть runtime-проверяемый drop-флаг
    // означающий что объект был инициализирован во время выполнения этой ветки!
    // подробнее смотрите https://doc.rust-lang.org/nomicon/drop-flags.html
    &format!("{uri}?{query}")
} else {
    uri
}

С++ не позволяет себе такой неявности и сопряженных с ней некладных расходов.

Также копированине в тернарном операторе — это в некотором роде безопасное поведение по умолчанию: если результатом станет копия, то это точно не висячая ссылка!

Последние примеры. Шестой.

  test_default_getter(m, 42, []{  return "default"s; });

То же самое что и с четвертым и пятым, только возвращается чистое временное значенине, а не ссылка

const std::string& : std::string даст в результате std::string. И левый аргумент всегда будет скопирован.

Последний, седьмой пример

    test_default_getter(m, 42, []{ return "default"; });

Здесь же мы получаем const std::string& : const char*. Указатель неявно приводится к std::string. Значит, для правого нужно будет создавать временный объект. Условного создания временных объектов в C++ нет — копируй левый аргумент!


Ознакомившись с этим поведением я вспомнил все те десятки и сотни раз, когда я видел или сам писал

const auto& value = config.hasValue(key) ? config.GetValue(key) : "default";

не подозревая что я всегда делаю копию... Зато работало!


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

Посмотрим, как еще мы можем себе что-нибудь отстрелить. Я упоминал, что автоматическое продление времени жизни в Rust еще облегчает работу с полиморфными объектами.

class Base {
public:
    virtual void foo() const {
        std::cout << "base\n";
    };
};

class Derived: public Base {
public:
    void foo() const override {
        std::cout << "derived\n";
    };
};

void test_ternary_inheritance(bool cond, const Base& a) {
    const auto& x = cond ? Derived() : a;
    x.foo(); 
}

int main() {
    const Derived d;

    test_ternary_inheritance(true,  d);
    test_ternary_inheritance(false, d);
}

Поскольку мы теперь знаем, как работает тернарный оператор и что в райнтайме он по условию время жизни не продляет, можно относительно легко понять, что в обоих случаях произойдет копирование a. А вместе с ним и слайсинг — только подобъект базового класса будет скопирован. И дважды будет выведено base.

А что если попробовать наоборот?

void test_ternary_inheritance_derived(bool cond, const Derived& a) {
    const auto& x = cond ? Base() : a;
    const auto& y = cond ? a : Base();
    x.foo(); 
    y.foo();
}

int main() {
    const Derived d;

    test_ternary_inheritance_derived(true,  d);
    test_ternary_inheritance_derived(false, d);
}

В свете всего того что мы уже увидели, результат окажется весьма неожиданным... Ошибка компиляции!

https://godbolt.org/z/dYEbG8fn3

<source>: In function 'void test_ternary_inheritance_derived(bool, const Derived&)':
<source>:28:26: error: operands to '?:' have different types 'Base' and 'const Derived'
   28 |     const auto& x = cond ? Base() : a;
      |                     ~~~~~^~~~~~~~~~~~
<source>:29:26: error: operands to '?:' have different types 'const Derived' and 'Base'
   29 |     const auto& y = cond ? a : Base();
      |   

И это же замечательно. Если код ошибочный, а слайсинг это чаще всего ошибка, то он не должен компилироваться. По крайней мере так происходит в GCC 14.2 и Clang 18.1. C MSVC 19 же все молча компилируется.

Если же мы просто уберем const из параметра

void test_ternary_inheritance_derived(bool cond, Derived& a) {
    const auto& x = cond ? Base() : a;
    const auto& y = cond ? a : Base();
    x.foo(); 
    y.foo();
}

И вот оно снова и под Clang и под GCC компилируется, как ожидается, со слайсингом.