вторник, 24 апреля 2012 г.

Полный ноль (С++,С++11,C++/CLI)

Полковник курсантам: 
- Характеристики аппаратуры: функционирует при температурах от -300°C до +300°С. 
Голос из зала: - Извините, но ученые не знают таких температур, абсолютный ноль -273°С. 
- Аппаратура секретная, ученые могли и не знать!

    В новый стандарт С++11 было добавлено ключевое слово nullptr, идентифицирующее нулевой указатель. В этой статье приведен подробный обзор nullptr. Мы обсудим причины появления нового "нуля", его характеристики и некоторые возможные ошибки, возникающие при использовании различных "нулей".  Рассмотрим особенности использования nullptr в С++/CLI и, наконец, рассмотрим идиомы, связанные с nullptr, позволяющие использовать некоторые его преимущества в средах, которые не поддерживают новый стандарт.
   Итак, nullptr это ключевое слово, которое фактически представляет собой литерал нулевого указателя, являющийся rvalue константой. Оно было введено для того, чтобы избежать ряда возможных ошибок и недостатков, возникающих при использовании литерального 0 и макроопределения NULL. Рассмотрим нули более подробно.
   Макроопределение NULL изначально задумывалось для обозначения значения нулевых указателей. Одна из его реализаций имеет следующий вид:

#ifdef __cplusplus
#define NULL    0
#else
#define NULL    ((void *)0)
#endif

   У некоторых читателей может возникнуть вполне закономерный вопрос: почему в С++ (в отличие от С) нельзя определить NULL как ((void *)0)?  Дело в том, что во-первых, в отличие от С, С++ является более типобезопасным и неявные преобразования из void* в указатели других типов невозможны. Во-вторых, в С++ есть особая разновидность указателей: указатели на члены классов, которые обычно реализуются как специальные структуры данных, размер которых часто превышает размер обычного указателя. Для таких указателей даже явное преобразование из void* является ошибкой. Однако согласно стандарту С++98 (и его более поздним вариантам) литеральный 0 любого интегрального типа может быть неявно преобразован в указатель любого типа, включая указатели на члены класса.
   Поскольку с указателями может использоваться литеральный 0 любого интегрального типа, то в некоторых реализациях NULL может быть определен как 0L, 0UL и т.д.
   Рассмотрим некоторые проблемы, возникающие при использовании нулей. Допустим, мы имеем доступ к следующим объявлениям функций (которые могут подключаться из разных заголовочных файлов):

void DoSomeWork(int);  #1
void DoSomeWork(int*); #2

   Тогда DoSomeWork(0) приведет к вызову функции #1, поскольку типом 0 является int. Вызов DoSomeWork(NULL) менее предсказуемый и платформо-зависимый, поскольку зависит от определения NULL. Если NULL определен как 0, то результат будет аналогичен предыдущему (что, во многих случаях, свидетельствует об ошибке, поскольку при использовании NULL чаще всего имеется ввиду нулевой указатель). Если же NULL определен, скажем, как 0u, то компилятор выдаст ошибку, поскольку более чем один прототип перегруженной функции будет одинаково соответствовать переданному аргументу. Получается для того, чтобы вызвать функцию #1 мы должны написать DoSomeWork(0), а для того, чтобы вызвать функцию #2 - DoSomeWork(static_cast<int*>(0)). Проблема усугубляется тем, что мы можем не подозревать о том, что прототип функции #1 доступен в текущем пространстве имен.
   В шаблонных выражениях данная проблема приобретает более выраженный характер. Рассмотрим следующую реализацию рассматриваемой функции (которая не является образцовой, её вид, в первую очередь, обусловлен нашим желанием избежать обсуждения rvalue references и perfect forwarding в этой статье):
void AcceptPointer(void *);
 
template<typename T>
void DoAnotherWork(T arg) {
   AcceptPointer(arg);
}
 
AcceptPointer(0);      // OK
int* intPtr = 0;
DoAnotherWork(intPtr); // OK
DoAnotherWork(0);      // Ошибка: невозможно преобразовать int в void*
   Специальные свойства литерального нуля теряются как только он перестает быть литералом. Этот недостаток входит в состав более общей проблемы, известной под названием forwarding problem.
   Для решения этих проблем и предназначен nullptr:
DoSomeWork(nullptr);   // Вызывается функция #2
DoAnotherWork(nullptr);// OK
   Ключевое слово nullptr доступно при использовании следующих компиляторов, начиная с указанной версии (список не исчерпывающий): gcc 4.6, MSVC 10, clang 2.9.
   Рассмотрим ключевые характеристики nullptr, благодаря которым он решает описанные выше проблемы (и не только их):  
   1. Однозначно идентифицирует нулевой указатель. Неявно конвертируется только в любой тип указателя (включая указатели на члены классов) и в bool:
double *doublePtr = 0;      // эквивалентно double *doublePtr = nullptr;
if(doublePtr == nullptr) {  // эквивалентно if( !doublePtr )
   //...
}
int intValue = nullptr;     // Ошибка
    2. Имеет тип std::nullptr_t экземпляры которого всегда являются нулевыми указателями. Благодаря наличию типа и возможностям его неявного конвертирования в любой тип указателя решается forwarding problem для нулевого указателя. Указатели других типов могут быть только явно приведены к типу std::nullptr_t и результатом всегда будет нулевой указатель.
   3. Литеральные нули интегральных типов могут быть неявно преобразованы в тип std::nullptr_t (об этом почему-то часто умалчивается, что приводит к ложным сообщениям об ошибках в компиляторах):

std::shared_ptr<Entity> GetEntity() {
   return 0;       // OK. Вызывается shared_ptr(nullptr_t)
   //return false; // OK. Понятие интегрального типа многогранно, 
                   //     компилятор имеет право не выдавать предупреждение...
}

   Отметим, что даже после добавления в стандарт nullptr, определение NULL осталось прежним вместо того, чтобы стать синонимом nullptr, поскольку это привело бы к тому, что множество старого кода, полагающегося на интегральные характеристики NULL, перестало бы работать. 
   Если бы мы жили в идеальном мире (ну, или почти в идеальном, учитывая особенности последнего примера), то на этом можно было бы закончить обсуждение nullptr. Однако реальность вносит свои коррективы.
   В С++/CLI (управляемый С++ для платформы .NET от Microsoft) ключевое слово nullptr существовало задолго до принятия стандарта С++11. Причем, управляемый nullptr существенным образом отличается от нативного. В частности, он не имеет типа. Это может привести к проблемам в проектах в которых сочетается управляемый и нативный код, поскольку в этом случае nullptr всегда трактуется как управляемый. Для решения этой проблемы Microsoft вводит ключевое слово __nullptr, которое всегда соответствует нативному nullptr и может использоваться как в управляемом, так и в нативном коде.
   Если ваш компилятор не поддерживает ключевое слово nullptr, то можно воспользоваться соответствующей идиомой, которая так и называется "nullptr". Для того, чтобы лучше понять как работает эта идиома, рассмотрим сначала идиому определителя типа возвращаемого значения (Return Type Resolver), которая является ее основой.
   Предположим, что нам требуется написать инициализатор числовых типов, который бы возвращал значение, равное половине максимально возможного значения для данного типа (пример надуманный и служит только для демонстрации идиомы). Этот инициализатор мы хотели бы использовать в выражениях вида:

double var = ПоловинаОтМаксимальноВозможногоЗначения; // псевдокод
   
   Прямое решение - написать шаблонную функцию вида:

template<typename T>
T HalfOfMaxValue() {
   return std::numeric_limits<T>::max()/2;
}
   Имея в распоряжении данную шаблонную функцию мы можем написать:

double var = HalfOfMaxValue<double>();
   
   Обратите внимание на то, что мы вынуждены явно задавать шаблонный аргумент функции HalfOfMaxValue, поскольку он не может быть выведен автоматически. Это приводит к дублированию кода и, как следствие, увеличивает вероятность ошибок. Существует более чистое решение:

class HalfOfMaxValue {
public:
   template<typename T>
   operator T () const {
      return std::numeric_limits<T>::max()/2;
   }
};
   Мы преобразовали шаблонную функцию в класс, содержащий шаблонный оператор приведения типа, что дает нам возможность "захватить" тип переменной при инициализации:

double var = HalfOfMaxValue(); // теперь ОК
  
  Здесь вместо вызова функции создается временный экземпляр класса HalfOfMaxValue, который благодаря шаблонному оператору неявно преобразуется в double. Это и есть идиома определителя типа возвращаемого значения.
   Можно усовершенствовать пример еще больше и избавиться от круглых скобок в конце HalfOfMaxValue:
const // Константный объект
class HalfOfMaxValue {
public:
   template<typename T>
   operator T () const {
      return std::numeric_limits<T>::max()/2;
   }
} halfOfMaxValue = {}// Value-initialization агрегата. Требуется, поскольку в классе
                       // константного объекта нет определенного пользователем конструктора  
 
double var = halfOfMaxValue;
   Это и сделано в реализации идиомы nullptr (приведенная ниже реализация это вариант подхода, предложенного Скоттом Майерсом в его книге More Effective C++):

const // Это константный объект
class nullptr_t  {
public:
   template<class T>
   inline operator T*() const {     // конвертируемый в любой тип нулевого указателя,
      return 0;
   }
   template<class C, class T>
   inline operator T C::*() const { // включая указатели на члены классов,
      return 0;
   }
private:
   void operator& () const;         // адрес которого нельзя получить
} nullptr = {};
  
   Данная реализация помещается в заголовочный файл (это возможно для константного инициализированного объекта), который необходимо подключать для использования nullptr.
   Идиома nullptr имеет следующие недостатки:
- необходимо подключать заголовочный файл;
- имя nullptr может быть скрыто другим идентификатором в программе;
- nullptr не может быть преобразован в bool. Выражения типа if(nullptr)... недопустимы;
- проблемы при использовании с обобщенными константными выражениями (constexpr), в случае когда компилятор их поддерживает;
- некоторые популярные компиляторы (в частности, gcc) неверно диагностируют некоторые варианты использования nullptr. Например, gcc 4.1.1 - 4.5 выдает ошибку при сравнении на равенство nullptr с указателем на функцию класса и т.д.
   По указанным причинам идиома nullptr не получила широкого распространения.

   Подведем итоги:
  
   Если ваш компилятор поддерживает ключевое слово nullptr - начинайте его использовать. Это поможет вам избежать ряда ошибок, возникающих при использовании литерального 0. В противном случае, задумайтесь о целесообразности использования идиомы nullptr, может быть в вашей ситуации это то, что нужно.

2 комментария:

  1. Кстати C++\CLI не позволит использовать NULL вместо nullptr

    ОтветитьУдалить
    Ответы
    1. Позволит. VS2010:
      #include "stdio.h"
      //...
      int *i = NULL; //OK
      int *j = nullptr; // OK
      int ^k = nullptr; // OK
      int ^l = NULL; // OK. Но с предупреждением компилятора.

      Удалить