Очень долго процесс конструирования объектов оставался неизменным, что-то было унаследовано от старого доброго C, что-то было добавлено благодаря появлению классов. Шли годы и в старых методах конструирования были выявлены серьезные изъяны. Большая часть которых, несомненно, является чисто синтаксической(т.е. наличие изъяна ни каким образом на производительность не влияла). В настоящей статье речь пойдёт о новшествах, появившихся в новом стандарте C++, относящихся к новому синтаксису инициализации. Остальные новшества будут рассмотрены в части 2.
Единообразная инициализация
Экскурс в историю
Все мы знаем, что для инициализации статического массива некоторым набором элементов существуют крайне простой и удобный синтаксис:
int a[10] = {1, 2, 3, 4, 5, 6,};
Вышеописанный массив инициализирует первые 6 элементов значениями, которые описаны в фигурных скобках, оставшиеся же элементы заполняются нулями(т.е. значениями по умолчанию).
Кроме массивов, подобным способом инициализации могли похвастаться и различные простые(POD) структуры данных:
struct A
{
int a;
int b;
};
class B
{
public:
int a;
int b;
};
union C
{
int a;
int b;
};
int main()
{
A a = {1, 2};
B b = {2, 3};
C c = {1};
}
И на этом на список можно завершить, т.к. всё удобство инициализации было строго ограничено рамками вышеозначенных способов. Никакого вмешательства в процесс инициализации C++, образца 2003 года, не терпел. Очень многие, в то время, задавались вопросом: ”доколе пользовательские типы данных будут считаться второсортными?”. Наверно, многим из вас приходилось писать код, в тестах или еще где, очень похожий на следующий:
std::vector<int> vec;
vec.push_back(1);
vec.push_back(7);
vec.push_back(-3);
vec.push_back(4);
vec.push_back(-15);
vec.push_back(22);
...
Несомненно, был выход писать это по другому:
int a[] = {1, 7, -3, 4, -15, 22 ...};
std::vector<int> vec(&a[0], &a[sizeof(A)/sizeof(int) - 1]);
Но, согласитесь, писать 2 строчки вместо одной удовольствие не из приятных. К тому же это добавляло сущностей, которые к делу отношения не имеют совершенно.
Еще одним важным моментом, здесь, является факт, что использование фигурных скобок всегда давало жестко регламентированный результат(о котором написано выше), в то время как для некой динамической инициализации всегда использовались пользовательские конструкторы и, соответственно, круглые() скобки. В связи с этой особенностью связан любопытный баг, который очень часто вводил(вводит?) в ступор новичков в C++, да и, откровенно говоря, не только новичков. Давайте рассмотрим следующий код:
class A
{
public:
A(): m_i(7)
{}
private:
int m_i;
};
int main()
{
A a();//#1
}
Что делает строчка #1? Новичок уверенно отвечает: “Определение объекта a типа A, который конструируется посредством вызова конструктора по умолчанию!”. В логике ему не окажешь, и такой ответ должен бы быть правильный, но, к сожаление таковым не является. Правильным ответом будет: “Объявление функции a которая не принимает аргументов и возвращает объект типа A”. Вот такие вот весёлые последствия использования круглых скобок в различных контекстах.
Новое время
Понимая всю плачевность ситуации с проблемами описанными выше, комитет стандартизации разродился унифицированным синтаксисом инициализации. И в борьбе круглых и фигурных скобок победили последние. Начиная разматывать клубок с последней проблемы, продемонстрируем как она решается в новом стандарте:
class A
{
public:
A(): m_i(7)
{ }
private:
int m_i;
};
int main()
{
A a{};
A b = {};
};
Вуаля! Заменяем круглые скобки на фигурные и наша запись больше никак не может трактоваться двояко. Это определение объекта с вызовом конструктора по умолчанию! Разумеется, данная нотация справедлива не только для конструктора по умолчанию, но и для любого другого:
class A
{
public:
A(int i, int j, int k): m_i(i), m_j(j), m_k(k)
{ }
private:
int m_i;
int m_j;
int m_k;
};
int main()
{
A a{1, 2, 3};//Эквивалентно A a(1, 2, 3)
};
Кроме того, фигурные скобки могут быть использованы без имени типа, к примеру в операторе return:
A foo()
{
return {1, 2, 3};//Эквивалентно return A(1, 2, 3)
}
Или как параметр функции:
void foo(A a)
{
a;
}
int main()
{
foo({1, 2, 3});//Эквивалентно foo(A(1, 2, 3))
};
Неправда-ли удобная нотация? Теперь нет нужды постоянно повторять тип, компилятор уже его знает(прям как с auto)! Просто конструируем то, что нам нужно с использование фигурных скобок. Хотя в примерах я использую константные значения, вместо них, с таким же успехом могут быть переменные и объекты.
Кстати, старое, доброе выделение памяти под массив теперь тоже можно совмещать с инициализацией:
int* a = new int[5]{1, 2, 3, 4, 5};
При всём удобстве новой нотации, не обошлось и без “капли дёгтя в бочке мёда”. Одним из недостатков подобной нотации является запрет на “сужение”(narrowing) типа. Означает это следующие: Если тип аргумента в фигурных скобках отличается от типа, который ожидает конструктор и, следовательно, требуется неявное преобразование, то следующее преобразование запрещено:
- Число с плавающей точкой в целое
- Из long double в double, или float и из double во float, за исключением случаев, когда это константное значение и оно умещается в “суженный” тип.
- Из целого числа(или enum) в число с плавающей запятой, за исключением случав, когда это константное значение и оно умещается в тип с плавающей точкой.
- Из целого числа(или enum) более высокого порядка, в целое число более низкого(например из long long в long), за исключением случаев, когда это константное значение и оно умещается в “суженный” тип.
Примеры:
int i1 = {1};//верно
int i2 = {1L};//верно
int i3 = {9999999999999999999L};//ошибка, п.4
long long longI{0};//верно
int i4 = {longI};//ошибка п.4
int i5{1.0};//ошибка п.1
int i6{1.0f};//ошибка п.1
float f1{1.0};//верно
double doubleF{1.0};//верно
float f2{doubleF};//ошибка п.2
float f3{9999999999999999999.0};//ошибка п.2
float f4{9999999999999999999L};//ошибка п.3
float f5{99999L};//верно
float f6{longI};//ошибка п.3
Хотя для кого-то это далеко не дёготь(для меня к примеру), а торжество “буквы закона”. Ведь подобное поведение всегда сопровождалось предупреждениями в любом уважаемом компиляторе. И тот факт, что с новым синтаксисом предупреждение превратилось в ошибку лишь добавляет уверенности в корректности кода(на тот случай, если по каким-либо причинам проект, с которым вы работаете не использует флаг, который интерпретирует все предупреждения как ошибки). Хотя данная особенность и не является дёгтем для всех, есть и другая особенность, но проявляющаяся в некотором другом случае, который мы рассмотрим чуть позже.
Рассмотрев новый синтаксис мы, попутно, решили последнюю проблему из нашего списка(списка из двух проблем :)), но, всё же, осталось непонятным как прикрутить этот новый синтаксис к первой проблеме, проблеме заполнения вектора и ему подобных. Не будешь же писать конструктор на бесконечное количество аргументов, пусть даже с существующими variadic templates. Комитет по стандартизации, тоже решил, что пора облегчить жизнь страждущим и в результате появился std::initializer_list<T>
Список инициализации
Начнем с того, что новый синтаксис для заполнения массивов типа std::vector, и даже std::map всё таки появился. Никаких отличий от того, чем раньше обладали лишь статические массивы нет:
std::vector<int> vec = {1, 2, 3, 4 ,5 , 6};
std::map<std::string, int> map = {{"first", 1}, {"second", 2}, {"third", 3}};
Никаких push_back’ов и insert’ов! Правда не забывайте о “сужении”, здесь оно точно так же действует. Никаких отличий. И в этом вся сила нового, унифицированного синтаксиса. Теперь множество различных(но смежных) действий производится с использованием одного синтаксиса.
Теперь давайте разберемся как такое стало возможным, т.е. как std;:vector может принимать подобный список и наполняться, за счёт него, элементами. Это стало возможным за счёт нового, специально введенного типа std::initialzer_list<T>, который находится в заголовке <initializer_list>. Дабы обозначить, что наш тип(в нашем случае vector) может принимать списки неопределенного размера, мы должны определить специальный конструктор, который принимает std::initialzer_list в качестве единственного аргумента, или же все последующие аргументы имеют значения по умолчанию. Т.е. новый конструктор для вектора будет выглядеть так:
vector(const std::initializer_list<T>& list)
{
....
}
или так:
vector(std::initializer_list<T> list)
{
....
}
для std::map конструктор будет выглядеть так:
map(std::initializer_list<pair<T, U>> list)
{
....
}
initializer_list является довольно легковесным типом и вы сами можете в этом убедится посмотрев его заголовок. Вся основная “магия” нового синтаксиса сокрыта в недрах компилятора и нам предоставляется тонкий интерфейс к оной. Происходит же это примерно так: создается некий промежуточный массив, в который помещаются все элементы из списка инициализации({…}). После этого конструируется initializer_list, который хранит в себе указатель на начало и конец этого временного массива. После чего initializer_list передается в конструктор и мы можем делать с ним всё, что захотим.
Давайте рассмотрим простенький пример со списком:
#include <iostream>
#include <initializer_list>
class A
{
public:
A(std::initializer_list<int> list)
{
for(auto& item : list)
{
std::cout << "item=" << item << "\n";
}
}
};
int main()
{
A a{23,321,321,3,213,213,12};
};
Т.к. initializer_list имеет методы begin и end, которые возвращают необходимые итераторы мы можем использовать его в цикле for нового формата. Также мы можем иметь несколько конструкторов с initializer_list, разных типов:
class A
{
public:
A(std::initializer_list<int> list)
{
for(auto& item : list)
{
std::cout << "Integral item=" << item << "\n";
}
}
A(std::initializer_list<std::string> list, int def = 0)
{
for(auto& item : list)
{
std::cout << "String item=" << item << "\n";
}
}
};
int main()
{
//A(std::initializer_list<int> list)
A a{23,321,321,3,213,213,12};
//A(std::initializer_list<std::string> list, int def = 0)
A b{"one", "two", "three", "eleven"};
};
Есть в стандарте и особое правило, которое регламентирует то, как конструктор с initializer_list участвует в перегрузке: если используется синтаксис списка инициализации({…}), то конструктор с initializer_list имеет превосходство перед любыми другими конструкторами, если список не пуст. Если список пуст, то вызывается конструктор по умолчанию, если он присутствует. И вот тут мы находим настоящий кусок дёгтя.
Рассмотрим пример:
std::vector<int> vec{5};
//#1
assert(vec.size() == 5);
assert(vec[0] == 0);
//#2
assert(vec.size() == 1);
assert(vec[0] == 5);
Как вы полагаете, какой набор assert’ов сработает? Разумеется все готовы к подвоху и отвечают #1. Вы абсолютно правы, а происходит это потому, что конструкторы с initializer_list имеют превосходство, а значит {5} это не “создать вектор из 5 элементов”, а “создать вектор из одного элемента и присвоить этому эдементу 5”. Поэтому для создания вектора из 5-и элементов придется использовать старый синтаксис с круглыми скобками. Более того, придется всегда следить, что у вас нет перегруженных конструкторов, которые могут быть “случайно заменены”, конструктором со списком. Или же, если таковые есть, использовать тип очень аккуратно и помнить о том, чем это может быть чревато. Довольно серьёзный недостаток, но с ним, к сожалению, ничего не поделаешь. В остальном же новый синтаксис просто шикарен.
Резюме
Итак, в современном C++ сущности могут быть инициализированы одним из следующих методов:
- Старый способ, использующий фигурные скобки.
- Инициализация списком, т.е. вызов конструктора с initializer_list
- Прямое конструирование, т.е. вызов конструктора без initializer_list у класса, или же инициализация значением у базового типа.
- Старый способ, использующий круглые скобки.
PodStruct pod = {1, 2, 3};//#1
InitStruct initialized = {1, 2, 3};//#2
int array[] = {1, 2, 3};//#1
int multiArray[2][2] = {{1, 2}, {3, 4}};//#1
int* pArray = new int[3]{1, 2, 3};//#3
InitStruct default{};//#3
InitStruct nonDefault{1.0};//#3
int i = {};//#3
int j{5};//#3
std::map<int, int> map = {{1, 2}, {3, 4}, {5, 6}};//#2