Использование неполных объявлений в C++

d_drkepr6iwyakjgcrzjd0p8cvo.png
Продолжаем серию «C++, копаем вглубь». Цель этой серии — рассказать максимально подробно о разных особенностях языка, возможно довольно специальных. Это восьмая статья из серии, список предыдущих статей приведен в разделе 6.


C++ относится к языкам со статической типизацией, то есть тип переменных определяется на стадии компиляции, но в ряде случаев компилятору для компиляции правильного кода достаточно знать, что то или иное имя является именем какого-то пользовательского типа (класса, структуры, объединения, перечисления), а полное объявление типа не нужно. В этом случае можно использовать неполное объявление (incomplete declaration), называемое еще упреждающим или предваряющим (forward declaration). Типы с неполным объявлением называются неполными.


Использование неполных объявлений позволяет решить ряд проблем, традиционно свойственных коду, написанному на С++. Отметим следующие:


  1. Можно уменьшить количество включений заголовочных файлов в другие файлы проекта, что сокращает время компиляции, снижает замусоривание пространств имен неиспользуемыми именами, предупреждает потенциальные конфликты имен;
  2. Можно реализовать решения, полностью разделяющие интерфейс и реализацию (непрозрачные указатели);
  3. Можно разрывать циклические зависимости;
  4. Можно снизить использование нетипизированных указателей void*, что повышает надежность и читаемость кода.

Грамотное использование неполных объявлений — один из признаков профессионального кода.


Оглавление
Оглавление

Введение
1. Передача параметров по значению
2. Указатели и ссылки
  2.1. Объявление, инициализация, копирование, присваивание
    2.1.1. Указатели
    2.1.2. Ссылки
  2.2. Приведения типа
  2.3. Указатели на функцию-член и член класса
  2.4. Использование оператора delete
3. Перечисления
4. Другие случаи использования неполных объявлений
  4.1. Псевдонимы
  4.2. Пространство имен
  4.3. Вложенные типы
  4.4. Перегрузка и компоновка функций с параметрами неполного типа
  4.5. Шаблоны
    4.5.1. Неполное объявление шаблона класса
    4.5.2. Конкретизация шаблона класса аргументом неполного типа
  4.6. Типы, объявленные в С коде
5. Идиомы, использующие неполные типы
  5.1. Непрозрачные указатели
  5.2. Циклические зависимости
6. Список статей серии «C++, копаем вглубь»
Список литературы



Введение

Неполное объявление является инструкцией, которая открывается ключевым словом из следующего списка: class, struct, union, enum, enum class, за которым следует собственно имя. Вот примеры неполных объявлений:


class X;
union U;
enum class S;

Неполное объявление с ключевым словом enum или enum class может иметь дополнительный спецификатор базового типа, например:


enum class S : short;

Подробнее см. раздел 3.


Для шаблона класса также можно сделать неполное объявление, например:


template class R;

Подробнее см. раздел 4.5.1.


Про неполные объявления можно почитать у Скотта Мейерса [Meyers1].



1. Передача параметров по значению

Можно объявить функцию, параметры которой передаются по значению и тип параметров имеет неполное объявление. То же самое относится и к возвращаемому значению.


class X; // неполное объявление

void Foo(X x);
X Foo();

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


Можно объявить указатель на функцию, имеющую параметры неполного типа, передаваемые по значению, или возвращаемое значение неполного типа и инициализировать соответствующей функцией.


class X; // неполное объявление

void Foo(X x);
X Foo();

void (*pFoo1)(X x) = Foo;
X (*pFoo2)() = Foo;


2. Указатели и ссылки

Чаще всего неполные типы используются для объявления указателей и ссылок. Указатели и ссылки устроены одинаково для любого типа и поэтому возможности написания содержательного кода расширяются.



2.1. Объявление, инициализация, копирование, присваивание



2.1.1. Указатели


Указатель на неполный тип может быть типом объявляемой переменной. Такие переменные можно инициализировать, копировать и присваивать без доступа к полному объявлению типа. Указатель на неполный тип может быть типом параметра или типом возвращаемого значения при объявлении функции. Такие функции можно вызывать без доступа к полному объявлению типа. (Правда, некоторые компиляторы могут выдавать предупреждение.) Вот пример:


class X; // неполное объявление

X* CreateX();
void ProcessX(X* px);
// ...
X* px1 = nullptr; // объявление и инициализация
X* px2;           // объявление без инициализации
px2 = CreateX();  // присваивание и вызов функции
ProcessX(px2);    // вызов функции

Указатель на неполный тип может быть членом класса/структуры/объединения. Для таких членов возможна инициализация, копирование и присваивание без доступа к полному объявлению. Вот пример:


class X; // неполное объявление

class XHolder
{
    X* m_pX;                              // объявление
// ...
public:
    XHolder(X* px) : m_pX(px) { /*   */ } // инициализация
    XHolder() : m_pX(nullptr) { /*   */ } // инициализация
    X* GetX() { return m_pX; }            // копирование
    void SetX(X* px) { m_X = px; }        // присваивание
// ...
};

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



2.1.2. Ссылки


Ссылка на неполный тип может быть типом объявляемой переменной. Как и любые ссылки, такие переменные необходимо инициализировать, также можно копировать, полное объявление при этом не обязательно. Но вот присваивание невозможно. Дело в том, что присваивание для ссылок реализовано как присваивание переменных, на которые эти ссылки ссылаются, поэтому нужна реализация оператора присваивания, которую можно получить только из полного объявления типа. Ссылка на неполный тип может быть типом параметра и возвращаемого значения при объявлении функции. Такие функции можно вызывать без доступа к полному объявлению типа. Вот пример:


class X; // неполное объявление

X& CreateX();
void ProcessX(X& x);
// ...
X& rx = CreateX(); // объявление, инициализация и вызов функции
ProcessX(rx);      // вызов функции

Ссылка на неполный тип также может быть членом класса. Для таких членов обязательна инициализация в списке инициализации конструктора, также возможно копирование, полное объявление при этом не обязательно. Вот пример:


class X; // неполное объявление

class XHolder
{
    X& m_X; // объявление
// ...
public:
    XHolder(X& x) : m_X(x) { /*   */ } // инициализация
    X& GetX() { return m_X; } // копирование
// ...
};

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



2.2. Приведения типа


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


class X; // неполное объявление
class Y; // неполное объявление

void* p;
Y* py;
const X* pcx;
// ...
X* px1 = static_cast(p);
X* px2 = reinterpret_cast(py);
X* px3 = const_cast(pcx);

Следует обратить внимание на потенциальные ограничения — неполные классы не могут наследовать друг другу, поэтому static_cast<>() можно применять только в варианте приведения void* к типизированному неполному указателю и нельзя использовать dynamic_cast<>().



2.3. Указатели на функцию-член и член класса


Можно объявить переменную типа указатель на функцию-член или член класса с неполным объявлением.


class X; // неполное объявление

void (X::*pmf)(int);
double (X::*pmd);

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


class X; // неполное объявление
int Foo(X* px, int (X::*pmf)(int))
{
// ...
    return(px->*pmf)(42);
}


2.4. Использование оператора delete


Интересно (и потенциально опасно) то, что оператор delete, мможно применить к указателю на объект, тип которого имеет неполное объявление.


Оператор delete удаляет динамический объект, созданный оператором new. Реализация delete состоит из двух шагов: сначала вызывается деструктор объекта, потом освобождается память, занимаемая объектом. Для вызова деструктора нужно полное объявление типа объекта, а вот для освобождения памяти нет, необходимые для освобождения параметры фиксируются при выполнении оператора new.


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


MSVS:
warning C4150: deletion of pointer to incomplete type 'X'; no destructor called


Clang:
deleting pointer to incomplete type 'X' may cause undefined behavior [-Wdelete-incomplete]


MinGW:
[Warning] possible problem detected in invocation of delete operator: [-Wdelete-incomplete]


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


Рассмотрим пример:


class X; // неполное объявление

X* CreateX();
void Foo()
{
    X* px = CreateX();
    delete px;
}

Если в точке вызова delete недоступно полное объявление класса X, то код все равно будет компилироваться без ошибки (но будет предупреждение). Если, кроме этого, компоновщику доступно определение CreateX(), то код будет компоноваться, а если еще CreateX() возвращает указатель на объект, созданный оператором new, то вызов Foo() будет успешно выполняться, деструктор класса X при этом не будет вызван. (Можно даже придумать реализацию CreateX(), которой не требуется полное объявление X).


Ситуация эта не надумана, она может возникнуть при использовании класса типа интеллектуального указателя или класса-дескриптора. Но ее можно перевести в разряд ошибок, для этого перед вызовом delete надо использовать выражение sizeof(X). Если X неполный тип, то при компиляции этого выражения возникает ошибка. Например, можно вставить инструкцию:


static_assert(sizeof(X) > 0, "can't delete an incomplete type");

Именно так сделана защита от удаления объекта неполного типа для стандартных интеллектуальных указателях в MSVC. (Напомним, что в соответствии со стандартом sizeof(X) больше нуля, если X является типом с полным объявлением, даже если он не содержит данных.)



3. Перечисления

В С++ можно использовать два типа перечислений:


  1. Старые, без области видимости (unscoped). Эти перечисления объявляются с помощью ключевого слова enum, они унаследованы из С;
  2. Новые, с областью видимости (scoped). Эти перечисления объявляются с помощью ключевого слова enum class, они появились в С++11.

Скотт Мейерс в [Meyers2] подробно разбирает особенности каждого из этих типов.


Перечисления реализуются компилятором с помощью одного из целочисленных типов, который называется базовым типом (underlying type). В соответствии со стандартом, для новых перечислений по умолчанию в качестве базового типа используется int. Также базовый тип можно указать явно.


enum class S : short { /* ... */ };

В С++11 появилась возможность указать базовый тип явно и для старых перечислений.


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


Для новых перечислений и старых перечислений с известным базовым типом можно использовать неполные объявления. В этом случае можно объявлять переменные, а также инициализировать, копировать и присваивать такие переменные. Такие переменные могут быть членами класса/структуры/объединения. Вот пример, когда такая переменная является членом класса:


enum class S : short; // неполное объявление

class SHolder
{
    S m_S;                              // объявление
// ...
public:
    SHolder(S s) : m_S(s) { /* ... */ } // инициализация
    S GetS() const { return m_S; }      // копирование
    void SetS(S s) { m_S = s; }         // присваивание
// ...
};

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


В соответствии со стандартом неполные объявления для старых перечислений без спецификатора базового типа вызывают ошибку. Например, MSVS выдает следующее сообщение:


error C3432: a forward declaration of an unscoped enumeration must have an underlying type


Но следует иметь в виду, что некоторые компиляторы при определенных настройках нарушают расширяют стандарт и определяют базовый тип для старых перечислений без спецификатора базового типа. Если в MSVC свойство проекта 'C/C++/Language/Disable language extentions' установлено в 'No' (такое значение используется в настройках C++ проекта по умолчанию), то для старых перечислений без спецификатора базового типа будет использовано int в качестве базового типа и, соответственно, неполные объявления разрешены. Если это свойство установлено в 'Yes', то возникает ошибка. Аналогично можно настроить Clang.



4. Другие случаи использования неполных объявлений

4.1. Псевдонимы


Типу с неполным объявлением (а также указателю, ссылке или другому типу, связанному с неполным) можно давать псевдоним и далее использовать его везде, где допустимо использование неполного типа. Псевдоним можно объявлять традиционным способом с помощью ключевого слова typedef или более новым способом, в котором используется ключевое слово using (появился в C++11).


class X; // неполное объявление
typedef X X1;
using X2 = X;
using PX = X*;

Использовать их можно, например, так:


void Foo(const X& x);
void Foo1(X1& x);
void Foo2(X2& x);
void Foo(PX px);


4.2. Пространство имен


Если полное объявление типа находится в пространстве имен, например Ns, то неполное объявление вне этого пространства имен надо сделать так:


namespaсe Ns
{
    class X; // неполное объявление
}

Использовать его вне Ns можно, например, так:


void Foo(Ns::X& x);


4.3. Вложенные типы


Неполный тип может быть вложенным (nested)), то есть объявленным внутри класса/структуры/объединения. Например:


class H
{
    class X; // неполное объявление

    X* m_pX;
// ...
};

В этом случае полное объявление класса X должно быть сделано так:


class H::X
{
// ...
};

Нельзя сделать одновременно неполное объявление класса и вложенного в него класса.



4.4. Перегрузка и компоновка функций с параметрами неполного типа


Механизм перегрузки работает и для неполных типов.


class X; // неполное объявление
class Y; // неполное объявление
void Foo(X* px);
void Foo(Y* py);
// ...
X* px;
// ...
Foo(px); // void Foo(X* px);

В данном случае в точке вызова Foo полное объявление класса X может быть недоступно, но разрешение перегрузки работает. Компоновщик найдет нужную функцию, если она где-то определена.


Следует обратить внимание на потенциальные ограничения — неполные классы не могут наследовать друг другу, а наследование влияет на правила перегрузки.



4.5. Шаблоны



4.5.1. Неполное объявление шаблона класса


Шаблон класса также может иметь неполное объявление, например:


template  class String; // неполное объявление

Шаблон с неполным объявлением можно конкретизировать (в том числе и неполным типом), результатом, естественно, будет неполный тип. Для полученного типа можно объявить псевдоним, а также использовать везде, где допустимо использование неполного типа. Вот пример для этого объявления:


void Foo1(const String& s);
using StringA = String;
void Foo2(const StringA& s);

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


#include 

void Foo(std::ifstream& file);


4.5.2. Конкретизация шаблона класса аргументом неполного типа


Шаблон класса с полным объявлением может быть конкретизирован шаблонным аргументом неполного типа. Для полученного типа можно объявить псевдоним. Дальнейшее использование такого типа зависит от устройства шаблона и контекста конкретизации членов. Если для конкретизации членов не нужно полное объявления аргумента шаблона, то компиляция пройдет без ошибки. Например, могут оказаться доступным объявление экземпляра, инициализирующие и копирующие операции и, возможно, что-то еще. Вот пример:


template 
class PtrHolder
{
    T* m_pT;
// ...
public:
    PtrHolder(T* pt) : m_pT(pt) {}
    PtrHolder(const PtrHolder&) = default;
    PtrHolder& operator=(const PtrHolder&) = default;
// ...
};

Для такого шаблона будет корректным следующий код:


class X; // неполное объявление

using XPtr = PtrHolder; // псевдоним
XPtr x1(nullptr);          // объявление и инициализация
XPtr x2(x1);               // объявление и инициализация копированием
x1 = x2;                   // присваивание

Следует быть осторожным при использовании оператора delete, см. раздел 2.4.


В качестве другого примера можно привести стандартные интеллектуальные указатели.


class U
{
    class X; // неполное объявление
    std::unique_ptr m_pX;
// ...
};

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



4.6. Типы, объявленные в С коде


К C++ проектам можно подключать модули, написанные на C. В этом случае заголовочные C-файлы должны включатся в C++ код внутри блока extern "C". В C пользовательские типы принято объявлять с использованием псевдонима, это позволяет немного упростить использование таких типов. Вот как в этом случае объявляется структура:


typedef struct Tag
{
// ...
} Name;

В таком объявлении Tag и Name могут совпадать (это хороший стиль), Tag может быть опущен, в этом случае Name будет псевдонимом для анонимной структуры. Аналогично объявляются объединения и перечисления.


Если есть Tag и Name, то в коде на С++ неполное объявление структуры делают так:


extern "C"
{
    typedef struct Tag Name;
}

Использовать его можно, например, так:


void Foo(Name* ps);

Если Tag и Name совпадают, то неполное объявление упрощаются:


extern "C"
{
    struct Name;
}

Если Tag опущен, то неполное объявление невозможно.


Неполное объявление для C-объединения делается аналогично, надо только заменить struct на union. Неполное объявление C-перечисления не поддерживается, так как в соответствии со стандартом его базовый тип не фиксирован.


В самом C также есть поддержка неполных объявлений. Для неполного объявления структур и объединений используется приведенные выше конструкции, естественно, без extern "C". Поддерживаются описанные выше случаи использования неполных объявлений, если они совместимы с синтаксисом C.



5. Идиомы, использующие неполные типы

5.1. Непрозрачные указатели


Рассмотрим класс:


// файл X.h
class XImpl; // неполное объявление

class X
{
    XImpl* m_pImpl;
// ...
};

Подобный класс может иметь функции-члены, которые просто делегируют функциональность соответствующим функциям-членам класса XImpl. Если все такие вызовы будут реализованы в X.cpp — файле, где определен класс X, то в заголовочном файле X.h полного объявления класса XImpl не потребуется. Таким образом, мы будем иметь полное отделение интерфейса от реализации, пользователям класса X не нужно знать интерфейс класса XImpl. В этом случае указатель m_pImpl называют непрозрачным указателем (opaque pointer), он полностью скрывает устройство класса XImpl.


Подобная техника получила целый букет звучных названий: брандмауэр компиляции (сompiler firewall), идиома Pimpl (pointer to implementation idiom), класс-дескриптор (handle class), идиома handle/body (handle/body idiom), d-pointer и даже улыбка Чеширского Кота (Cheshire Cat smile).


Полное отделение интерфейса от реализации дает определенные преимущества: уменьшается время компиляции/сборки проекта, части проекта становятся более независимыми, упрощается командная разработка. Подобный прием позволяет достичь истинной инкапсуляции, когда детали реализации скрыты полностью. К недостаткам можно отнести повышение накладных расходов, связанных с вызовом функций-членов — эти расходы удваиваются. Про технику непрозрачных указателей можно почитать у Скотта Мейерса [Meyers1].


Класс реализации можно объявить как закрытый вложенный класс. Это позволяет ограничить использование класса реализации.


// файл X.h
class X
{
    class XImpl; // неполное объявление

    XImpl* m_pImpl;
// ...
};

Для такого класса могут возникнуть проблемы с использованием оператора delete, описанные в разделе 2.4.


Скотт Мейерс в качестве непрозрачного указателя рекомендует использовать не обычный указатель, а интеллектуальный указатель std::unique_ptr<>.


class X
{
    class XImpl; // неполное объявление
    std::unique_ptr m_pImpl;
// ...
};

В этом варианте появляются некоторые важные детали, подробности см. [Meyers2].


Для полного отделения интерфейса от реализации существует альтернативная техника — интерфейсные классы. Использование таких классов подробно описано в другой статье автора.



5.2. Циклические зависимости


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


// файл X.h
class Y; // неполное объявление
class X
{
    void Foo(Y y);
// ...
};

// файл Y.h
class X; // неполное объявление
class Y
{
    void Foo(X x);
// ...
};

// файл X.cpp
#include "X.h"
#include "Y.h"
void X::Foo(Y y)
{
// ...
}

// файл Y.cpp
#include "Y.h"
#include "X.h"
void Y::Foo(X x)
{
// ...
}


6. Список статей серии «C++, копаем вглубь»
  1. Перегрузка в C++. Часть I. Перегрузка функций и шаблонов.
  2. Перегрузка в C++. Часть II. Перегрузка операторов.
  3. Перегрузка в C++. Часть III. Перегрузка операторов new/delete.
  4. Массивы в C++.
  5. Ссылки и ссылочные типы в C++.
  6. Объявление и инициализация переменных в C++.
  7. Константность в C++.


Список литературы

[Meyers1]
Мэйерс, Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2014.


[Meyers2]
Мейерс, Скотт. Эффективный и современный C++: 42 рекомендации по использованию C++11 и C++14.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2016.

© Habrahabr.ru