Во время работы наша команда постоянно сталкивается с некоторыми особенностями языка, которые могут быть неизвестны рядовому C++ программисту. В этой статье мы расскажем о том, как работает, казалось бы, обыденная вещь – forward-декларации классов.

Предыстория

Для начала ответьте на вопрос: скомпилируется ли следующий код?

namespace soundtouch
{
    class SoundTouch
    {
    public:
        class TDStretch *pTDStretch;
    };

    class TDStretch
    {
    public:
        void *getInput() { return nullptr; }
    };
}

auto bbb = soundtouch::TDStretch {};

int main(int argc, char** argv)
{
    soundtouch::SoundTouch st;
    st.pTDStretch = &bbb;
    return !!st.pTDStretch->getInput();
}

Если вас смутила декларация указателя pTDStretch, то поздравляю – она смутила и меня. Но перед тем, как разобраться в этом поведении, предлагаю ознакомиться с предысторией того, как мы отыскали сей интересный артефакт.

Этот код (в несокращённом варианте) мы встретили во время переработки системы типов в PVS-Studio. Перед нами появился вот такой "дифф" – это различие в срабатывании, которое возникает между стабильной и тестовой версией анализатора при прогоне на тестовой базе:

V547 Expression 'psp' is always true. - MISSING IN CURRENT SoundTouch.cpp 493

class SoundTouch : public FIFOProcessor
{
private:
    /// Time-stretch class instance
    class TDStretch *pTDStretch;
};

/// Class that does the time-stretch (tempo change) effect for the processed
/// sound.
class TDStretch : public FIFOProcessor
{
public:
    /// Returns the input buffer object
    FIFOSamplePipe *getInput() { return &inputBuffer; };
};

/// Returns number of samples currently unprocessed.
uint SoundTouch::numUnprocessedSamples() const
{
    FIFOSamplePipe * psp;
    if (pTDStretch)
    {
        psp = pTDStretch->getInput();
        if (psp)                     // <=
        {
            return psp->numSamples();
        }
    }
    return 0;
}

Проблема была в том, что мы не могли связать вызов функции getInput с её декларацией. В процессе отладки выяснилось, что мы не можем найти объявление класса TDStretch. И действительно – при просмотре кода его не найти! Но откуда мы находили декларацию этой функции раньше, до переработок? И почему мы находили её во внешнем классе? Должно быть, это какая-то ошибка, и этот код на самом деле не должен компилироваться.

Упрощаю пример для Compiler Explorer и проверяю на компилируемость... Стоп, что?!? Оно компилируется?!? Но почему? Спрашиваю об этом тимлида – он тоже в недоумении. Пришлось идти и раскапывать стандарт. В процессе совместных раскопок выяснилось, что это на самом деле ожидаемое поведение. Давайте посмотрим, что же лежит внутри сундука...

Ожидаемое поведение

Итак, смотрим в стандарт C++, чтобы понять, в каком месте должен быть объявлен класс. Оно определяется в разделе [basic.scope.pdecl] p7.

Если декларация имеет вид:

class Foo;

то объявление будет находиться в той области видимости, в которой находится декларация. Например:

struct Foo; // declaration of class '::Foo'

// definition of previously-declared class '::Foo'
struct Foo
{
  struct Bar; // declaration of class '::Foo::Bar'
};

// definition of previously-declared class '::Foo::Bar'
struct Foo::Bar
{
};

namespace Baz
{
  struct Qux; // declaration of class '::Baz::Qux'
}

// definition of previously-declared class '::Baz::Qux'
struct Baz::Qux
{
};

Иначе, если класс объявлен в параметрах или возвращаемом значении функции, то класс будет находиться в namespace, где объявлена функция. Например:

void func(class Foo *p); // declaration of class '::Foo'

struct Bar
{
  struct Baz *funcReturningClassPtr(); // declaration of class '::Baz'
};

namespace Qux
{
  struct Quux *anotherFunction(); // declaration of class '::Qux::Quux'
}

// definition of previously-declared class '::Baz'
struct Baz {};

// definition of previously-declared class '::Qux::Quux'
struct ::Qux::Quux {};

Иначе класс будет находиться в ближайшем блоке или namespace. Например:

struct Foo
{
  class Bar *baz; // declaration of class '::Bar'
};

void func()
{
  struct Baz *ptr; // declares local class 'Baz'
  struct Baz {}; // definition of previously-declared class 'Baz'
}

namespace Qux
{
  struct Baz
  {
    struct Quux *ptr; // declares class '::Qux::Quux'
  };
}

Для этого случая есть одно исключение – friend-декларации. Они на самом деле не внедряют никаких новых имён.

Более подробно правило описано в стандарте (см. ссылку выше).

В нашем случае используется последний пункт. Однако стоит дописать class TDStretch; в класс SoundTouch, то код компилироваться не будет.

Lookup

Стоит отметить ещё один важный пункт. Не всегда конструкция class Foo; декларирует новый класс. Она может ссылаться на уже объявленный класс, причём не обязательно он должен находиться в текущей области видимости.

Данное поведение регламентируется стандартом в разделе [basic.lookup.elab] под пунктом 2:

If the elaborated-type-specifier has no nested-name-specifier, and unless the elaborated-type-specifier appears in a declaration with the following form:

class-key attribute-specifier-seqopt identifier ;

the identifier is looked up according to [basic.lookup.unqual] but ignoring any non-type names that have been declared.

If the elaborated-type-specifier is introduced by the enum keyword and this lookup does not find a previously declared type-name, the elaborated-type-specifier is ill-formed.

If the elaborated-type-specifier is introduced by the class-key and this lookup does not find a previously declared type-name, or if the elaborated-type-specifier appears in a declaration with the form:

class-key attribute-specifier-seqopt identifier ;

the elaborated-type-specifier is a declaration that introduces the class-name as described in [basic.scope.pdecl].

Изначально, когда компилятор встречает данную конструкцию, он выполняет unqualified lookup указанного имени. Если имя было найдено, то данная конструкция ассоциируется с найденной декларацией.

Иначе, если имя найдено не было, оно декларируется по правилам, которые мы рассмотрели в предыдущем разделе. Например:

struct Foo
{
  class Bar *ptr; // declaration of class '::Bar'
};

namespace Baz
{
  class Bar *anotherPtr; // uses previously-declared class '::Bar'
}

Заключение

В этой статье мы рассмотрели интересную и неочевидную особенность языка. Надеемся, что она оказалась полезной и поможет вам в чтении и написании кода. А те, кто ответил, что код в первом примере – компилируется, можете считать себя гуру C++!

Если вас интересуют и другие тонкости языка C++, то приглашаю в наш блог. Вот несколько интересных технических статей:

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Sergey Larin. C++ subtleties: so, you've declared a class....

Комментарии (7)


  1. aamonster
    00.00.0000 00:00
    +2

    Так. Если по простому, то всё сводится к тому, что

    class TDStretch *pTDStretch;

    эквивалентно

    class TDStretch;
    TDStretch *pTDStretch;

    – я правильно понял? (всегда писал только вторым способом, он прозрачен).

    А то картинка с неймспейсами несколько запутала, всё пытался понять, при чём тут они...

    Upd: перечитал повнимательней, ещё и не эквивалентно... Ну, лишний повод никогда не писать как в первом варианте.


    1. cdriper
      00.00.0000 00:00
      +1

      не эквивалентно, потому что если дело происходит внутри класса, то первое объявит класс во внешней области, а второй вариант -- внутри класса


      1. aamonster
        00.00.0000 00:00
        +3

        Угу. Говорю ж – лишний повод никогда так не писать. То, что пишется в классе – должно оставаться в классе. На месте PVS я б добавил диагностику на такой код...


  1. Deosis
    00.00.0000 00:00

    // uses previously-declared class '::Bar'

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


  1. Simon_Luner
    00.00.0000 00:00

    Пример с struct ::Qux::Quux {}; не компилируется на GCC https://godbolt.org/z/9nen1Yjda


    1. cerg2010cerg2010 Автор
      00.00.0000 00:00
      +1

      Но работает на других компиляторах: https://godbolt.org/z/GqxddxTGz

      Судя по всему это баг GCC: https://gcc.gnu.org/bugzilla/show_bug.cgi?id=66892


  1. BraveBanana
    00.00.0000 00:00
    -2

    Никогда я не понимал программистов, которые используют такие атавизмы времён С на пустом месте. Использование конструкций из С в С++, да ещё и с сущностями С++ - верный способ выстрелить себе в ногу. Вы что там, боитесь написать лишнюю строчку кода?