Конструирование в C++11. Часть 2: Работа над ошибками

В части 1 мы рассмотрели новый синтаксис, а теперь пришло время к тому, что я называю “работой над ошибками”. Всё представленное в данной статье так или иначе, как мне кажется, является доработкой концепций, которые были просто-напросто просмотрены в первом стандарте.

Равноправие членов

Каждый из нас знает простой синтаксис инициализации константных членов класса:

class A
{
    const int a = 42;
};

При этом, любое неконстантное поле, которое требуется инициализировать, необходимо инициализировать в конструкторе:

class A
{
    const int a = 42;
    int references;
public:
    A(): references(0)
    {

    }
};

Странная несправедливость, вы не находите? На мой взгляд это простая недоработка изначального стандарта, которая, наконец, исправлена в новом стандарте C++11.

Теперь права константных и не-константных членов класса уравнены и они могут быть инициализированы в определении класса:

class A
{
    const int a = 42;
    int references = 0;
};

Заметьте, что конструктор нам больше не нужен, т.к. единственным его назначением была инициализация поля references, которое теперь успешно инициализировано без необходимости определения собственного конструктора; мы можем положиться на генерируемый конструктор по умолчанию. Довольно удобная конструкция, особенно когда у нас много конструкторов, где надо инициализировать одно и то же поле. Давайте рассмотрим несколько вариаций на тему инициализации в определении класса:

struct A
{
    int b = 1;
    int c{b + 1};
};

int main()
{
    A obj;
    std::cout << obj.b << " " << obj.c;
};

Несложно догадаться, что этот код выведет

1 2

А что если изменить порядок?

struct A
{
    int c{b + 1};
    int b = 1;
};

Что выведет этот код? Правильно,- неизвестно что, т.к. члены класса всегда конструируются в том порядке, в котором их декларировали. С появлением такого способа инициализации новичкам, как мне кажется, будет проще запомнить это правило: Всегда инициализируй члены класса в том порядке, в котором они объявлены. Ведь здесь действует тоже правило, что и для списка инициализации членов в конструкторе. Более того, новый способ инициализации, по сути, лишь “синтаксический сахар”, надстройка над списком инициализации членов в конструкторе, т.е. компилятор вместо программиста “подставляет” код инициализации в каждый конструктор. Вышеприведенный код абсолютно эквивалентен нижеприведенному:

Версия выводящая

1 2
struct A
{
    int b; 
    int c;
    
    A(): b(1), c(b + 1), 
    {
    }
};

И выводящая непредсказуемый результат:

struct A
{
    int c;
    int b; 
    
    A(): b(1), c(b + 1), 
    {
    }
};

Кстати, вы заметили, что  при инициализации c мы использовали b + 1, т.е. мы использовали выражение, а, следовательно, мы можем использовать любой код при инициализации, так же как мы использовали в списке инициализации. К примеру. мы можем инициализировать b результатом возвращенным из функции:

int answer()
{
    return 42;
}

struct A
{
    int b = answer(); 
};

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

int answer()
{
    return 42;
}

struct A
{
    int b = answer(); 
    int c{[this](){return b + 1;}()};
};

В продолжение, рассмотрения нового способа инициализации членов, немного усложним пример:

struct A
{
    int b = 1; 
    int c{b + 1};
    int d;
    int a = d++;
    A(int value): d(value)
    {
    }
};

int main()
{
    A obj{3};
    std::cout << obj.a << " " << obj.b << " " << obj.c << " " << obj.d;
};

Тут всё тоже очевидно, этот код выведет:

3 1 2 4

А что выведет код, если мы немного его изменим:

struct A
{
    int b = 1; 
    int c{b + 1};
    int d;
    int a = d++;
    A(int value): d(value), a(5)
    {
    }
};

может быть он выведет

5 1 2 4

?

Нет, он выведет

5 1 2 3

!

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

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

Т.е. мало того, что само значение из инициализации в определении класса будет проигнорировано, так еще и никакие побочные эффекты, которые могли бы быть вызваны этой инициализацией не будет произведены! Именно поэтому выражение d++ никогда не было выполнено и мы имеем тройку в хвосте нашего вывода. Помните об этом свойстве, оно достаточно коварно.

Выражайтесь яснее

Еще одно новшество стандарта связано с пользовательским операторами преобразования типов:

struct A
{
    operator int()
    {
        return 42;
    }
};

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

struct A
{
    operator int()
    {
        return 42;
    }

    operator char*()
    {
        return "A";
    }
};

int main()
{
    
    std::cout << A() << "\n";
};

В MSVS этот код выводит 42, а это явно не то, что нам хотелось. Оставим за бортом рассуждения как лучше реализовывать получения имени класса и остановимся на нашем примере. Конечно, мы можем использовать явное преобразование при выводе имени, но, лично для меня, это слишком тяжкий груз. Получается чересчур много ненужной писанины. Гораздо проще этот пример можно решить с помощью нового стандарта:

struct A
{
    explicit operator int()
    {
        return 42;
    }

    operator char*()
    {
        return "A";
    }
}

Обратите внимание на ключевое слово explicit перед оператором, именно оно даёт нам нужное поведение. Теперь, при добавлении любого нового оператора преобразования мы помечаем его explicit и оставляем только один оператор, который может быть использован неявно – operator char*(). И наша проблема решена.

Значение ключевого слова explicit точно такое же как и в случае с конструктором,- если желаете преобразовать тип A в тип B извольте указать это явно. Вот и всё.

Кому-то может показаться, что данное нововведение не существенно, но я бы не стал считать его таковым. К примеру, все мы знаем, что получить C-строку из std::string можно с помощь. c_str(), но было бы неплохо иметь оператор преобразования в C-строку. Понятно почему его нет до сих пор – если бы он был, то он бы возвращал “нестабильный” указатель в местах, где этого никто не  ждёт. Но сейчас, когда пользователь может явно указать, что он хочет, не разрешая никаких неявных преобразований, возможно, он всё таки появится.

Еще одним примером является библиотека Qt, которая содержит следующие макросы:

QT_NO_CAST_FROM_ASCII

QT_NO_CAST_TO_ASCII

QT_NO_CAST_FROM_BYTEARRAY

Все они относятся к преобразованию типов и могут быть заменены на более элегантное решение с помощь.нового, явного преобразования типов.

Царь-конструктор

Я думаю многие(если не каждый) сталкивался с необходимостью писать функцию init для класса, в котором имеется более одного конструктора. Более того, я полагаю, многие из вас сталкивались с тем, что забывали вызывать эту функцию init в одном из конструкторов и это было чревато самыми неприятными последствиями. Все эти потуги с init выглядели настолько чуждо элегантной структуре классов, что если бы новый стандарт не решил данную проблему, раз и навсегда, это вызывало бы недоумение, мягко говоря.

Хотя возможность инициализации не константных членов класса частично решает данную проблему, остаются варианты, когда необходимо таки получить какие-то аргументы через конструктор. Да и очень часто инициализация выносится из заголовка специально, дабы не раздувать зависимости. Поэтому не будем считать данную возможность решением проблемы функции init

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

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

class Rectangle
{
public:
    Rectangle(size_t width, size_t height): 
        m_Width(width),
        m_Height(height)
    {
        std::cout << "Target ctor\n";
    }
    Rectangle(size_t width): 
        Rectangle(width, width)
    {
        std::cout << "Delegate ctor\n";
    }
private:
    size_t m_Width;
    size_t m_Height;
};

int main()
{
    Rectangle square(10);
};

Как вы понимаете, этот код даст следующий вывод:

Target ctor
Delegate ctor

Ни убавить, ни прибавить - всё достаточно просто. Цепочка конструкторов может быть любой длины, при этом основным конструктором считается первый вызванный конструктор. Из этого вытекает одно очень важное свойство. Все мы знаем, что если конструктор не был завершен за счёт брошенного исключения, то и деструктор не будет вызван. Здесь ничего не меняется. Но, в связи с появлением цепочки конструкторов, появилась новая ситуация, когда основной конструктор отработал, а, в последствии, в цепочке было брошено исключение. Так вот, если основной конструктор отработал, то деструктор будет вызван, в не зависимости от завершённости остальных конструкторов в цепочке.

class Rectangle
{
public:
    Rectangle(size_t width, size_t height): 
        m_Width(width),
        m_Height(height)
    {
        std::cout << "Rectangle(size_t width, size_t height)\n";
    }
    Rectangle(size_t width): 
        Rectangle(width, width)
    {
        std::cout << "Rectangle(size_t width)\n";
        throw std::string();
    }
    Rectangle(std::string): 
        Rectangle(42)
    {
        std::cout << "Rectangle(std::string)\n";
    }
    ~Rectangle()
    {
        std::cout << "~Rectangle()\n";
    }
private:
    size_t m_Width;
    size_t m_Height;

};

int main()
{
    try
    {
        Rectangle square("SQUARE");
    }
    catch(...)
    {

    };
};

Таким образом, за счёт делегирования конструкторов можно навсегда избавиться от написания init функций. Единственный случай, теперь, когда она может понадобиться это когда необходим вызов виртуальной функции при инициализации, т.к. эта проблема никуда, к сожалению, не ушла.

Конструкторонаследование

Еще одной ситуацией, которая заставляет многих C++ программистов писать “лишний” код является наследование, а именно: наследование конструкторов. Очень часто в классе наследнике определяется конструктор, только для того, чтобы вызывать конструктор базового класса. Отличным примером тут является иерархия QObject в Qt. Так каждый класса потомок, который хочет иметь возможность участвовать в местном авто-удалении должен передать родительский объект в консруктор класса QObject, который выглядит так:

QObject(QObject* pParent = nullptr)

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

Раз уж мы заговорили об этой проблеме, то, естественно, новый стандарт решает и её. И вот как выглядит это решение:

using A::A;

Где A это имя класса, конструкторы которого мы наследуем. Пример:

class A
{
public:
    explicit A(size_t i): m_i(i)
    {
        std::cout << "A(size_t i)\n";
    }
private:
    size_t m_i;
};

class B: public A
{
public:
    using A::A;
};

int main()
{
    B b(55);
};

Теперь давайте разберём, что же происходит при использовании данной using директивы: Происходит неявное объявление всех конструкторов класса родителя в классе потомке, за исключением копирующего и перемещающего конструкторов. Они не наследуются. Более того, существует ряд случаев, когда количество унаследованных конструкторов будет больше, чем родитель имеет оных. Все эти случаи связаны с параметрами по умолчанию и эллипсами(…). Так, если родитель имеет некий конструктор с параметрами по умолчанию, то на основании этого конструктора будет сгенерирован и унаследован набор конструкторов, в котором, помимо оригинала, будут присутствовать конструкторы с “обрезанным” количеством параметров. Где обрезанными могут быть параметры по умолчанию и эллипсы. Пример:

class A
{
public:
    explicit A(int i, int j = 12, int k = 35, ...) 
    {
    }
private:
    size_t m_i;
};


class B: public A
{
public:
    using A::A;
};

Класс B унаследует следующий набор конструкторов:

  • explicit B(int i, int j = 12, int k = 35, …)
  • explicit B(int i, int j = 12, int k = 35)
  • explicit B(int i, int j = 12)
  • explicit B(int i)

И, в довесок, будет иметь набор собственных конструкторов:

  • B()
  • B(B&&)
  • B(const B&)

Заметьте, что

  1. explicit наследуется вместе с конструктором; это, также. верно для constexpr, delete и спецификации исключений.
  2. Конструктор по умолчанию генерируется неявно, даже с учётом наличия унаследованного конструктора.

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

Кроме того, всё вышесказанное относится и к конструкторам-шаблонам; они наследуются по тем же правилам.

Равняйсь!

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

  • alignof – позволяет узнать по какой границе выравнивается тот или иной тип.
  • alignas – позволяет задать границу по которой должен быть выравнен тип или объект.

Оператор alignas имеет одно ограничение: если при помощи него осуществляется попытка выставить выравнивание, которое является более слабым (т.е. с меньшим значением), чем подразумевает (без всяких alignas) тип или объект, к которому этот оператор применяется, тогда программа считается некорректной (ill-formed) и компилятор даст ошибку. Также, если применяется несколько alignas, то выигрывает более сильный. Например:

#include <string>
#include <iostream>
#include <cassert>

class alignas(16) A
{
    long long variable;
};

bool IsAligned(void* address, size_t alignment)
{
    size_t addressVal = reinterpret_cast<size_t>(address);
    return addressVal % alignment == 0;
}

int main()
{
    std::cout << alignof(A) << "\n";
    alignas(32) alignas(64) A a;
    std::cout << std::boolalpha 
        << IsAligned(&a, 64) << "\n";
};

 

Будет выведено 16 (на x86 архитектуре) и true.

Оператор alignas может принимать в качестве операнда как неотрицательное целое, являющееся степенью 2-ки, так и имя типа. Если операндом выступает тип, то это эквивалентно следующей записи:

alignas(alignof(T))

Вместе с этими операторами появился целый ворох основанных на них компонентов STL, которые я не вижу смысла рассматривать отдельно: std::alignment_of, std::aligned_storage, std::aligned_union, std::align и так далее.