Pull to refresh
0
Инфопульс Украина
Creating Value, Delivering Excellence

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

Reading time 5 min
Views 12K
Original author: Andrzej
Возможно, вы уже знакомы с обобщёнными константными выражениями. Если нет — можете почитать о них, например, вот здесь). В этой статье я хочу поделиться своим опытом в использовании объединений (unions) в константных выражениях.

Объединения не очень популярны в ООП из-за той бреши в безопасности типов, которую они открывают, но иногда они предоставляют некоторые незаменимые возможности, которые лично я оценил при работе с Fernando Cacciola над черновиком std::optional.

Предисловие


Знаете ли вы библиотеку Boost.Optional? Если коротко, boost::optional<T> это «составная» сущность, которая может хранить любое значение типа T плюс одно дополнительное состояние, указывающее на отсутствие сохраненного значения. Это такое себе "nullable T".

Одна из особенностей boost::optional в том, что инициализируя этот объект «пустым» состоянием, объект типа T не создаётся вообще. Даже не вызывается его конструктор по-умолчанию: во-первых, для увеличения производительности, а во-вторых, у типа Т вообще может не быть конструктора по-умолчанию. Один из способов реализовать это — выделить некоторый буфер в памяти, достаточно большой для сохранения объекта типа Т и использовать явный вызов конструктора только в тот момент, когда объект точно необходимо создать.

template <typename T>
class optional
{
    bool initialized_;
    char storage_[ sizeof(T) ];
    // ...
};


Это только одна из идей возможной реализации. На практике, она не сработает из-за проблем с выравниванием — мы должны будем использовать std::aligned_storage; также можно использовать «исключающее объединение» — этот механизм детально описан в ACCU Overload #112. Конструкторы «пустого» состояния и «по реальному значению» могут быть реализованы следующим образом:

optional<T>::optional(none_t)  // null-state tag
{
  initialized_ = false;
  // оставляем storage_ неинициализированным
};
 
optional<T>::optional(T const& val)
{
  new (storage_) T{val};
  initialized_ = true;
};


Реализация деструктора, как вы уже могли догадаться, может быть следующей:

optional<T>::~optional()
{
  if (initialized_) {
    static_cast<T*>(storage_) -> T::~T();
  }
};


Проблема №1


В случае применения std::optional, есть некоторые особенности. Одно из них — тот факт, что std::optional<T> должен быть литеральным типом (объекты которого могут быть использованы, как константы на этапе компиляции). Одно из ограничений, которые Стандарт налагает на такие типы, это то, что они должны иметь тривиальный деструктор: деструктор, который не делает ничего. А как мы видим в примере выше, наш деструктор всё-же делает что-то нужное, то есть наша цель в общем недостижима. Хотя она может быть достижима для частных случаев (когда деструктор типа T тоже является тривиальным, например, если T = int, нам не нужно вызывать его деструктор). Отсюда следует практическое определение тривиального деструктора: это такой деструктор, который мы можем и вообще не вызывать без всякого вреда для программы.

Еще одно ограничение для литеральных типов это то, что они должны иметь по крайней мере один constexpr-конструктор. Этот конструктор (или несколько конструкторов) будет использован для создания констант на этапе компиляции. Однако, для избежания неопределенного поведения, Стандарт определяет ряд ограничений на constexpr-конструкторы и их типы, чтобы убедиться, что все поля данных в базовом типе будут определены.

Таким образом, наша реализация класса optional с соответственного размера массивом не будет работать, поскольку в конструкторе по значению массив не инициализируется в списке инициализации членов (перед телом конструктора). Мы могли бы заполнить массив нулями в конструкторе «пустого» состояния, но это всё накладные расходы на этапе выполнения. Мы также будем иметь аналогичную проблему и в случае использования std::aligned_storage. И также мы не можем использовать простое исключающее объединение (из ACCU Overload #112)

template <typename T>
class optional
{
  bool initialized_;
  union { T storage_ }; // анонимное объединение
};


Нельзя этого сделать потому, что при необходимости создать «пустой» объект класса optional мы должны будем либо оставить анонимное объединение неинициализированным (что недопустимо в constexpr-функциях), либо вызвать конструктор по-умолчанию для storage_ — но это противоречит нашей цели избежать ненужной инициализации объекта класса Т.

Проблема №2


Еще одна цель нашего дизайна — получить функцию, извлекающего из нашего объекта сохранённое в нём значение типа T. В реализации boost::optional, так же, как и в предложенном std::optional, для получения доступа к сохранённому значению используется оператор "*" (operator*). Для максимальной производительности мы не проверяем состояние родительского объекта (для этого пользователь может воспользоваться отдельной функцией) и прямо обращаемся к сохранённому значению типа T.

explicit optional<T>::operator bool() const // check for being initialized
{
    return initialized_;
};
	 
T const& optional<T>::operator*() const
// precondition: bool(*this)
{
    return *static_cast<T*>(storage_);
}


В то же время, мы хотим обращаться к operator* и на этапе компиляции, и в этом случае было бы неплохо, чтобы компиляция проваливалась в момент попытки обращения к неинициализированному значению в объекте. Может возникнуть соблазн использовать метод, описанный в других моих статьях по расчётам на этапе компиляции:

constexpr explicit optional<T>::operator bool()
{
  return initialized_;
};
	 
constexpr T const& optional<T>::operator*()
{
  return bool(*this) ? *static_cast<T*>(storage_) : throw uninitialized_optional();
}


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

Решение


Обе этих проблемы решаются использованием объединения типа T и заглушки:

struct dummy_t{};
 
template <typename T>
union optional_storage
{
  static_assert( is_trivially_destructible<T>::value, "" );
 
  dummy_t dummy_;
  T       value_;
 
  constexpr optional_storage()            // конструктор "пустого" состояния
    : dummy_{} {}
 
  constexpr optional_storage(T const& v)  // конструктор по значению
    : value_{v} {}
 
  ~optional_storage() = default;          // тривиальный деструктор
};


Есть специальные правила использования объединений в константных функциях и конструкторах. Нам необходимо инициализировать только один член объединения. (В самом деле, мы и не можем инициализировать одновременно несколько, поскольку они занимают одну и ту же область памяти). Этот член называется «активным». В том случае, если мы хотим оставить наше хранилище пустым — мы инициализируем заглушку. Это удовлетворяет все формальные требования инициализации на этапе компиляции, но поскольку наша заглушка dummy_t не содержит никаких данных, её инициализация не отнимает никаких ресурсов на рантайме.

Второе: чтение (строго говоря «вызов преобразования lvalue-to-rvalue») неактивного члена объединения не является константным выражением и его использование на этапе компиляции даёт нам ошибку компиляции. Следующий пример это демонстрирует:

constexpr optional_storage<int> oi{1}; // ok
constexpr int i = oi.value_;           // ok
static_assert(i == 1, "");             // ok
 
constexpr optional_storage<int> oj{};  // ok
constexpr int j = oj.value_;           // ошибка на этапе компиляции


Теперь наш класс optional (для типов Т с тривиальными деструкторами) может быть реализован так:

template <typename T>
// requires: is_trivially_destructible<T>::value
class optional
{
  bool initialized_;
  optional_storage<T> storage_;
 
public:
  constexpr optional(none_t) : initialized_{false}, storage_{} {}
 
  constexpr optional(T const& v) : initialized_{true}, storage_{v} {}
 
  constexpr T const& operator*()
  // precondition: bool(*this)
  {
    return storage_.value_;
  }
 
  // ...
};


Сообщение об ошибке на этапе компиляции в operator* не идеально: оно не говорит о том, что объект не был инициализирован, а лишь указывает на использование неактивного члена объединения. Тем не менее наша главная цель была достигнута: код с неверным доступом к значению не скомпилируется.

Вы можете найти базовую реализацию std::optional здесь: github.com/akrzemi1/optional
Tags:
Hubs:
+21
Comments 4
Comments Comments 4

Articles

Information

Website
www.infopulse.com
Registered
Founded
1992
Employees
1,001–5,000 employees
Location
Украина