Skip to content

Latest commit

 

History

History
282 lines (235 loc) · 15.2 KB

const_launder.md

File metadata and controls

282 lines (235 loc) · 15.2 KB

Неконстантные константы

В С++ есть ключевое слово const, позволяющее помечать значения как неизменяемые. Также в C++ есть const_cast, позволяющий этот const игнорировать. И иногда за это вам ничего не будет. А иногда будет неопределенное поведение, segfault, и прочие радости жизни.

Разница между этими «иногда» в том, что есть настоящие константы, попытка модификации которых — UB. А есть ссылки на константы, ссылающиеся не на константы. И раз на самом деле объект неконстантен, то модифицировать его можно без проблем.

Так, например, эту «фичу» можно эксплуатировать, чтобы не повторять один и тот же код для const и не-const методов класса

class MyMap {
public:
    // какой-то метод с длинной реализацией:
    const int& get_for_val_or_abs_val(int val) const {
        const auto it_val = m.find(val);
        if (it_val != m.end()) {
            return it_val->second;
        }
        const auto abs_val = std::abs(val);
        const auto it_abs = m.find(abs_val);
        if (it_abs != m.end()) {
            return it_abs->second;
        }
        throw std::runtime_error("no value");
    }

    int& get_for_val_or_abs_val(int val) {
        return const_cast<int&>( // отбрасываем const с результата.
            // находясь в неконстантном методе, мы знаем, что результат
            // в действительности не является константой — проблем не будет.
            std::as_const(*this) // навешиваем const, чтобы вызвать const-метод,
            // а не уйти в бесконечную рекурсию
            .get_for_val_or_abs_val(val));
    }

    void set_val(int val, int x) {
        m[val] = x;
    }
private:
    std::map<int, int> m;
};

По возможности, стоит избегать такого кода. Видно, что он очень хрупок — забытый или случайно удаленный std::as_const ломает его. И без настройки предупреждений, компиляторы об этом сообщать не торопятся.

Вместо использования const_cast и привнесения в мир C++ еще большей нестабильности, решить проблему дублирования кода можно с помощью шаблонного метода:

class MyMap {
public:
    // какой-то метод с длинной реализацией:
    const int& get_for_val_or_abs_val(int val) const {
        return get_for_val_or_abs_val_impl(*this, val); // *this — const&
    }

    int& get_for_val_or_abs_val(int val) {
        return get_for_val_or_abs_val_impl(*this, val); // *this  — &
    }

    void set_val(int val, int x) {
        m[val] = x;
    }
private:
    template <class Self>
    static decltype(auto) get_for_val_or_abs_val_impl(Self& self, int val) {
        auto&& m = self.m;
        if (it_val != m.end()) {
            return (it_val->second); // допскобки для вывода категории значения
        }
        const auto abs_val = std::abs(val);
        const auto it_abs = m.find(abs_val);
        if (it_abs != m.end()) {
            return (it_abs->second);
        }
        throw std::runtime_error("no value");
    }

    std::map<int, int> m;
};

У этого варианта есть свои недостатки, сломать его еще проще (скобки и decltype). Но, единожды его написав, можно рассчитывать на то, что странная магия отпугнет желающих этот код поправить.

Конечно, вместо decltype(auto) можно написать чуть больше кода, с явным указанием типов возвращаемых значений.

Const и оптимизации

Операции над иммутабельными данными отлично оптимизируются, распараллеливаются, и вообще ведут себя здорово.

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

И, например, итерирование по вектору не может быть оптимизировано в таком простом случае:

using predicate = bool (*) (int);

int count_if(const std::vector<int>& v, predicate p) {
     int res = 0;
     for (size_t i = 0; i < v.size(); ++i) { // значение v.size() нельзя
                                             // единожды сохранить в регистре
         if (p(v[i])) {  //конкретный p может иметь доступ к изменяемой ссылке на
                         // этот же самый v
             ++res;
         }
         // код метода size() придется выполнять на каждой итерации!
     }
     return res;
}

Пример, запрещающий оптимизацию, может быть не очевиден. Но тоже прост:

std::vector<int> global_v = {1};

bool pred(int x) {
    if (x == global_v.size()) {
        global_v.push_back(x);
        return true;
    } else {
        return false;
    }
}

int main() {
    return count_if(global_v, pred);
}

Этот код очень плох. Он не должен нигде встречаться; его никто не пропустит на ревью. Но так теоретически написать можно, поэтому оптимизация не выполняется.

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

Учитывая ограниченные возможности на автоматическую оптимизацию, подобный цикл переписывают (делая ту самую работу, которую ждали от компилятора):

int count_if(const std::vector<int>& v, predicate p) {
    int res = 0;

    for (auto x : v) { // range-based-for не обращается к size()
                       // а один раз считывает begin/end указатели и работает
                       // с ними; разность begin - end не рассчитывается
        if (p(v[i])) {
             ++res;
        }
    }
    return res;
}

В таком случае, при передаче «нехорошего» предиката, меняющего вектор, мы получим неопределенное поведение. Но это уже совсем другая история...

Вот менее тривиальный пример const, никак не способствующего оптимизации:

void find_last_zero_pos(const std::vector<int>& v,
                        const int* *pointer_to_last_zero) {
     *pointer_to_last_zero = 0;
     for (size_t i = 0; i < v.size(); ++i) { // мы опять не можем один раз
                                             // сохранить значение v.size()
         if (v[i] == 0) {
             // внутри вектора есть поля типа int* — begin, end
             // что если pointer_to_last_zero указывает на один из них?!?
             *pointer_to_last_zero = (v.data() + i);
         }
         // пересчитываем size!
     }
}

Оставаясь в рамках рекомендуемых практик написания C++ программ, мы не можем соорудить пример, который бы демонстрировал неприменимость оптимизации — нам мешает инкапсуляция. До приватных полей вектора мы не можем законно добраться.

Но ненормальный код не запрещен! Применим грубую силу:

int main() {
    std::vector<int> a = {1,2,4,0};
    const int* &data_ptr = reinterpret_cast<const int* &>(a); // ссылка на begin!
    find_last_zero_pos(a, &data_ptr);
}

И вот мы имеем парадоксальный результат: возможность написать явно некорректный код, запрещает компилятору оптимизировать цикл! И вся концепция неопределенного поведения как возможности для оптимизации (некорректного кода не бывает) разваливается.

Что ж, по крайней мере, для этого примера имеется некоторая стабильность: исходный цикл со счетчиком и переписанный на range-based-for закончатся на неопределенном поведении.

В современных языках (например, в Rust, благодаря семантике владения), все эти циклы могут быть успешно оптимизированы.

Const, время жизни и происхождение указателей.

Неизменяемые объекты всем хороши, кроме одного — это константные объекты в C++. Если они где-то засели, то их оттуда по-нормальному не выгонишь.

Что имеется в виду:

Пусть есть структура с константным полем

struct Unit {
    const int id;
    int health;
};

Из-за константного поля объекты Unit теряют операцию присваивания. Их нелья менять местами — std::swap не работает больше. std::vector<Unit> нельзя больше просто так отсортировать... В общем, сплошное удобство.

Но самое интересное начинается, если сделать что-то такое

std::vector<Unit> units;
unit.emplace_back(Unit{1, 2});
std::cout << unit.back().id <<  " ";
unit.pop_back();
unit.emplace_back(Unit{2, 3});
std::cout << unit.back().id <<  "";

В зависимости от того, смогли ли при реализации вектора задушить агрессивные оптимизации компилятора, такой код может вывести либо 1 2 (все хорошо), либо 1 1 (компилятор соптимизировал константное поле!)

Компилятор имеет право воспринимать происходящее следующим образом:

  • В векторе 1 элемент
  • Вектор не реаллоцировался.
  • Указатель на элемет в первом cout и во втором cout один и тот же.
  • И там и там используется константное поле
  • Я его уже читал при первом cout
  • Зачем мне его читать еще раз, это же константа!
  • Вывожу закэшированное значение.

К сожалению или к счастью, воспроизвести подобное поведение компилятора на практике не получается. Тем не менее, вот такой код, который может использоваться для реализации самописных std::optional, по стандарту содержит UB (и не одно!)

    using storage = std::aligned_storage_t<sizeof(Unit), alignof(Unit)>;
    storage s;
    new (&s) Unit{1,2};
    std::cout << reinterpret_cast<Unit*>(&s)->id << "\n"; // UB
    reinterpret_cast<Unit*>(&s)->~Unit(); // UB
    new (&s) Unit{2,2};
    std::cout << reinterpret_cast<Unit*>(&s)->id << "\n"; // UB
    reinterpret_cast<Unit*>(&s)->~Unit(); // UB

Правильный же вариант:

    using storage = std::aligned_storage_t<sizeof(Unit), alignof(Unit)>;
    storage s;
    auto p = new (&s) Unit{1,2};
    std::cout << p->id << "\n";
    p->~Unit();
    p = new (&s) Unit{2,2};
    std::cout << p->id << "\n";
    p->~Unit();

Но поддерживать указатель, взвращенный оператором new, не всегда возможно. Он занимает место, его надо хранить, что неэффективно при реализации optional: для int32_t будет нужно в три раза больше места на 64-битной системе (4 байта на storage + 8 байт на указатель)!

Поэтому в стандартной библиотеке с C++17 есть функция «отмывания» невесть откуда взявшихся указателей — std::launder.

    using storage = std::aligned_storage_t<sizeof(Unit), alignof(Unit)>;
    storage s;
    new (&s) Unit{1,2};
    std::cout << std::launder(reinterpret_cast<Unit*>(&s))->id << "\n";
    std::launder(reinterpret_cast<Unit*>(&s))->~Unit();
    new (&s) Unit{2,2};
    std::cout << std::launder(reinterpret_cast<Unit*>(&s))->id << "\n";
    std::launder(reinterpret_cast<Unit*>(&s))->~Unit();

Так и при чем тут const? «Настоящая» константность (переменные и поля объявленные с const) вместе с UB при использовании «неправильных» указателей, как раз и позволяют компилятору производить описанные спецэффекты.

Полезные ссылки

  1. https://isocpp.org/wiki/faq/const-correctness
  2. https://miyuki.github.io/2016/10/21/std-launder.html
  3. https://stackoverflow.com/questions/123758/how-do-i-remove-code-duplication-between-similar-const-and-non-const-member-func