Понятие конструктора существует во многих языках программирования и также является абстракцией, основное предназначение которой – абстрагироваться от процедуры конструирования объекта в программе. Если пробежаться глазами по примерам из предыдущих частей, можно заметить, что мы постепенно переходили от конструирования объектов типа rational
вручную к делегированию этого конструирования специальной функции (cons
). Сделав это мы установили конкретные правила конструирования объектов. Таким образом любой программист, который в будущем захотел бы воспользоваться типом данных rational
вынужден бы был создавать переменные посредством функции cons
.
Здесь есть несколько тонкостей. Во-первых, было бы неплохо добиться того, чтобы объекты можно было создавать без явного вызова специальной функции. Например, если пользователь не знает, какие первоначальные значения для числителя и знаменателя задать, конструктор мог бы определять их самостоятельно. Во-вторых, было бы удобно создавать новые объекты, используя уже существующие объекты напрямую, без явного вызова геттеров и последующей передачи их в конструктор, как было сделано в одном из примеров выше: return cons(r.p, r.q);
Было бы логичней если бы оно выглядело, например, так: return rational(r);
. В этом случае намерение данной инструкции было бы более явным: вызвать специальную функцию, которая носит имя того типа данных, который будет использоваться для создания нового объекта, при этом новый объект будет создан на базе существующего.
Это подразумевает установление для всех полей нового объекта величин, хранящихся в соответствующих полях аргумента. Используя последний пример, новый объект, который будет создан в результате этой операции, в своем поле p
будет содержать значение поля p
, которое принадлежит объекту r
, в своем поле q
будет содержать значение поля q
, которое принадлежит объекту r
.
Существуют и другие типы конструкторов. Например, мы могли бы написать такую функцию, которая создает объект типа rational
на основании переданной строки определенного вида (для строки “3⁄2” создать объект с полем p
равным 3, и полем q
равным 2). Или, как упоминалось ранее, можно было бы написать функцию, которая создает рациональные числа, в которых значения полей p
и q
задаются по умолчанию в том случае, если программист при создании объекта не задал их явно (например, чтобы исключить возможность появления 0 в знаменателе для неинициализированного q
).
Для всего этого мы бы могли бы сделать соответствующие функции внутри нашей структуры, дав им названия, которые указывают на их предназначение. И в некоторых языках программирования нам бы это пришлось сделать, так как они не позволяют создавать больше одного конструктора. В С++ конструкторов может быть сколько угодно, отличаться они должны только в том, какие аргументы они принимают. Например, ниже можно посмотреть на код, который конвертирует функцию cons
в конструктор.
rational(const int x, const int y)
{
assert(y != 0);
if (y < 0)
{
p = -x;
q = -y;
}
else
{
p = x;
q = y;
}
if (x != 0)
reduce(*this);
}
Пример выше содержит несколько синтаксических приемов, которые до сих пор нам нигде не встречались. В соответствии с правилами С++ у конструкторов есть ряд неявных свойств, о которых следует знать перед тем, как начать ими пользоваться.
Первое, что бросается в глаза - отсутствие типа данных перед именем этой функции. Второе - это некий указатель this
, используемый в последней строке функции. Третье - само имя функции повторяет имя структуры, внутри которой функция задана. Это продиктовано условностями реализации стандартов языка С++ на различных платформах. Как и в случае с ярлыком private
, это условности, которые устанавливаются на уровне компилятора для удобства и соблюдения единого стандарта. Чтобы разобраться с этими нюансами, нужно в первую очередь запомнить, что конструктор - это такая же функция, как и другие функции, которая имеет ряд синтаксических особенностей.
Первая особенность заключается в том, что на уровне кода пользователя конструкторы имеют сокращенную форму. Это выражается в том, что у них, как и у всех функций, находящихся внутри структуры, есть скрытый первый аргумент. У этого аргумента есть тип и название (наверное, многие из вас уже догадались, что речь идет о ключевом слове this
). Если бы этот аргумент задавался явно, выглядеть это могло бы следующим образом.
void cons(rational * _this, const int x, const int y)
{
assert(y != 0);
if (y < 0)
{
_this->p = -x;
_this->q = -y;
}
else
{
_this->p = x;
_this->q = y;
}
if (x != 0)
rational::reduce(*_this);
}
В этом фрагменте кода нет ничего загадочного. В функцию передается адрес, по которому хранятся данные рационального числа, и посредством этого адреса функция может изменять эти данные. Единственный вопрос, который закономерно возникает: как передать этот адрес в функцию, которая скрывает аргумент this
?
Здесь стоит вспомнить что было сказано в предыдущей части о вызове функций, которые объявлены внутри структуры. Как вы уже знаете, эти функции вызываются следующим образом (где b
– это переменная типа rational
): b.cons(20, 50);
Тот факт, что вызов такой функции возможен только посредством существующего объекта, намекает на то, что на самом деле происходит внутри функции. Функция изменяет состояние объекта, который ее вызывает (в нее явно не передается), из чего можно сделать правильный вывод, что объект туда передан неявно.
void cons(const int x, const int y)
{
assert(y != 0);
if (y < 0)
{
this->p = -x;
this->q = -y;
}
else
{
this->p = x;
this->q = y;
}
if (x != 0)
rational::reduce(*this);
}
Этот объект передается как указатель, для удобства названный this
, что намекает на природу изменяемого объекта. Слово “этот” указывает на переменную, которая использовалась для вызова функции (то есть, в контексте функции ее вызвал “этот объект”). В примере выше - это адрес переменной b
, соответственно b.p
примет значение 20, а b.q
- 50. Другими словами, если вне структуры к полям объекта надо обращаться через оператор-точку, применяемую к его имени в программе, то внутри структуры - через оператор-стрелочку, применяемую к его адресу в программе. С этого момента функции, объявленные внутри структуры, которые содержат неявный аргумент this
, мы будем называть методами.
Для конструкторов это тоже верно, то есть, они являются специальными методами. Главная же особенность конструкторов заключается в том, что они вызываются автоматически во время создания объекта в программе. Иначе говоря, каждая переменная соответствующего типа, объявленная в программе, в момент своей инициализации вызовет подходящий конструктор. Это также верно для структур, в которых не объявлено ни одного явного конструктора. В С++ компилятор автоматически генерирует конструкторы в меру своих способностей в случае их отсутствия. Такие конструкторы называются синтезированными. Правда, в большинстве ситуаций на это полагаться не рекомендуется, поэтому если ваш конструктор должен соблюдать определенный набор правил, его надо задавать явно.
Конкретней, для типа rational
следующая инструкция вызовет “конструктор по умолчанию”: rational a;
Конструкторы по умолчанию выполняют действия по инициализации полей структуры в тех случаях, когда для этого нет конкретных данных. В данном примере конструктор по умолчанию будет сгенерирован автоматически. Так как внутренние поля представлены примитивным типом int
, сгенерированному конструктору делать ничего не придется. Но на самом деле есть как минимум одно правило, которое должно выполняться даже для таких случаев: знаменатель не должен быть равен нулю. Для обработки такого сценария существует несколько возможных вариантов.
Первый способ заключается в предварительной инициализации полей внутри структуры.
struct rational
{
private:
int p{1};
int q{1};
};
Используя инициализаторы можно задать первоначальные значения для всех переменных такого типа. Это делается еще до вызова конструктора. Так можно гарантировать правильные значения для всех полей во избежание странных ошибок в дальнейшем.
Другой способ заключается в задании значений по умолчанию для одного из конструкторов.
struct rational
{
rational(const int x = 1, const int y = 1)
{
assert(y != 0);
if (y < 0)
{
p = -x;
q = -y;
}
else
{
p = x;
q = y;
}
if (x != 0)
reduce(*this);
}
private:
int p;
int q;
};
Строка rational(const int x = 1, const int y = 1)
использует свойство функций С++ устанавливать значения по умолчанию для аргументов (соблюдая определенные правила[1]). У этого свойства есть интересные последствия для конструкторов.
rational c = rational(9, 2);
assert(9 == c.get_p() && 2 == c.get_q());
rational d;
assert(1 == d.get_p() && 1 == d.get_q());
То есть, компилятор считает, что конструктор с параметрами одновременно является и конструктором по умолчанию. Если вызвать этот конструктор с конкретными значениями, они заменят аргументы по умолчанию при вызове функции, если же не передавать ничего, этот конструктор все равно будет вызван, но аргументы будут иметь значения по умолчанию, определенные в заголовке функции.
Третий способ, который мы рассмотрим - это запрет на конструирование объектов заданного типа без параметров. Если в вашей программе нет ситуаций, в которых рациональное число нужно было бы создавать без конкретных значений, лучше себя обезопасить от случайностей, указав на это в структуре, чтобы компилятор мог ловить все попытки так сделать.
struct rational
{
rational() = delete;
rational(const int x, const int y)
{
assert(y != 0);
if (y < 0)
{
p = -x;
q = -y;
}
else
{
p = x;
q = y;
}
if (x != 0)
reduce(*this);
}
private:
int p;
int q;
};
Здесь появляется новое ключевое слово delete
(не путать с оператором delete[2]). В данном случае оно указывает на то, что конструктор без параметров для этого типа использовать нельзя. Если теперь попробовать создать переменную без явной инициализации: rational a;
– компилятор покажет ошибку, “error: use of deleted function 'rational::rational()
”. Обратите внимание на то, что в конструкторе с параметрами больше нет значений по умолчанию. Как было показано выше, параметры по умолчанию превращают конструктор в конструктор по умолчанию, и последний фрагмент тогда имел бы два конструктора по умолчанию. То есть, нужно выбирать либо один вариант, либо другой. Совместить их не получится.
Следующий важный конструктор, который стоит реализовать – это конструктор копирования. Он нужен для того, чтобы в программе можно было создавать новые переменные как копии уже существующих:
rational a(2, 3);
rational b(a);
assert(2 == b.get_p() && 3 == b.get_q());
Здесь надо быть очень внимательным. Как и в случае с конструктором по умолчанию, компилятор генерирует конструктор копирования в определенных ситуациях, поэтому пример кода выше может быть вполне рабочим. Это происходит потому, что все поля в структуре rational
представлены примитивным типом данных int
. Компилятор знает как копировать данные типа int
и без нашей помощи. Для составных типов данных вроде массивов оставлять копирование на совести компилятора не следует. Для нашего случая конструктор копирования мог бы выглядеть следующим образом.
struct rational
{
rational(const rational & other)
{
this->p = other.p;
this->q = other.q;
}
private:
int p{1};
int q{1};
};
Как вы уже знаете, в конструктор передается невидимый аргумент rational* this
, и в пару к нему заявляется аргумент такого же типа, только как ссылка*, а не указатель. Таким образом мы поочередно копируем значения всех полей второго аргумента (other
в примере) в первый (this
). Существует и другая форма записи, использующая список инициализации.
struct rational
{
rational(const rational & other) : p(other.p), q(other.q) {}
private:
int p{1};
int q{1};
};
То есть, если достаточно просто скопировать данные из одной переменной в другую, язык позволяет сделать это до вызова тела конструктора.
Упражнения
Используя примеры из этой части необходимо написать конструктор, который создает объект типа rational
на основании переданной строки определенного вида (например, для строки “3⁄2” создать объект с полем p
равным 3, и полем q
равным 2).
rational e("3/15");
assert(1 == e.get_p() && 5 == e.get_q());
Один из вариантов для конвертирования строк в целые числа: использовать функцию sscanf
из библиотеки stdio
. Используя тот факт, что функция умеет обращаться со строками в качестве чисел, можно заранее подготовить две переменные, куда будет скопировано числовое представление правильно отформатированной строки. Но это же свойство функции означает, что любую другую строку так обработать не получится, исходная строка должна включать в себя символ “/”, иначе нет гарантии, что в числитель и знаменатель будут записаны правильные значения.
В этой же функции есть небольшая хитрость, которую можно использовать как желаемое поведение: sscanf
возвращает в качестве результата число, указывающее на то, сколько переменных было успешно сконвертировано. Если это число больше нуля, значит считывание строки в числитель прошло успешно. Например, такой вызов конструктора: rational e(“3 15”);
дал бы в виде результата рациональное число с 3 в числителе и 1 в знаменателе, то есть, все содержимое исходной строки после пробела было бы проигнорировано. В зависимости от дизайна нашего абстрактного типа данных это может быть как желаемым свойством данного конструктора, так и багом, который нужно исправить.
Если отказаться от использования sscanf
для достижения желаемого эффекта, можно было бы попробовать другие варианты конвертирования, с точным определением положения символа “/” в исходной строке. Например, имея некую целочисленную переменную offset = 0
, проверить наличие дробной черты можно было бы так.
while (str[offset++] != ‘/’ && strlen(str) > offset);
Если значение переменной после цикла равно длине строки, исходная строка либо содержит целое число, либо не содержит подходящего значения вовсе. Исходя из этого, можно разделить исходную строку на числитель и знаменатель и конвертировать их по отдельности, что вам и предлагается сделать в качестве самопроверки.
Конструктор переноса является еще одним “каноническим конструктором”, который в С++ принято реализовывать из соображений быстродействия программ. Останавливаться на нем очень подробно сейчас не стоит, но познакомиться с принципами его работы полезно. Работает он следующим образом. Для значений особого типа такой конструктор не копирует передаваемый ему объект, а “ворует” существующие в памяти значения, переписывая адреса этих значений на свой объект. Таким образом переданный в этот конструктор объект остается без действительных значений, так как все они были перенесены в другой объект. В зависимости от конкретной реализации стандартов языка С++ это может происходить автоматически[2]. В большинстве же случаев программисты самостоятельно отмечают переменные, которые они хотят перенести. Такие переменные принято называть ссылками на R-значения[3] и в коде отмечать как ссылка на ссылку (&&
).
Конструктор переноса - это такой конструктор, который принимает ссылку на R-значение в качестве первого аргумента. Например, для типа rational он мог бы выглядеть так: rational(rational && other) {}
Оставим тело конструктора пустым для начала и посмотрим на аргумент. Очевидно, что этот конструктор будет вызван только в случаях, когда объект заданного типа создается с помощью ссылки на R-значение, иначе будет вызван конструктор копирования. При этом в коде мы еще ни разу не встречали объекты, которые изначально были бы ссылками на R-значение. Для этого в С++ принято создавать (или использовать имеющуюся) функцию move, которая конвертирует значения, переданные в нее в ссылки на R-значения[4].
#include <utility>
rational g(std::move(e));
assert(1 == e.get_p() && 1 == e.get_q());
assert(1 == g.get_p() && 5 == g.get_q());
Порядок действий достаточно прост. Берем значения всех полей объекта-источника и копируем их в объект-приемник, затем “сбрасываем” значения полей объекта источника.
Здесь важно отметить два момента. Во-первых, оба поля переданного объекта представлены примитивными типами данных. Если бы эти поля были представлены составными типами, каждое поле в отдельности следовало бы предварительно конвертировать в ссылку на R-значение (с помощью вызова на них функции move
). Во-вторых, компилятор не дает никаких гарантий относительно того, чему будут равны значения полей объекта-источника после переноса, поэтому в конструкторе следует их установить в какие-то адекватные значения. Помните, что перенос мы делаем для экономии памяти, то есть, объект источник использовать больше не планируется, но если про это забыть и после переноса его применить в каком-то выражении, лучше бы он не содержал какой-то мусор.
В следующих частях будет рассмотрен более конкретный пример того, зачем такой конструктор нужен на практике. Самое главное, не зацикливайтесь на этих нюансах если что-то пока не понятно. Задача данного текста заключается не в том, чтобы объяснить особенности реализации языка С++, который используется только в ознакомительных целях. Загвоздка в том, что ссылки на R-значения являются способом реализации важных абстрактных идей, который выбрали разработчики С++, и вам следует иметь это ввиду.
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <utility>
int main(int argc, char const *argv[])
{
rational a(2, 3);
rational b(a);
cons(&b, 6, 9);
assert(2 == b.get_p() && 3 == b.get_q());
rational c = rational(9, 2);
assert(9 == c.get_p() && 2 == c.get_q());
rational d(10, 50);
assert(1 == d.get_p() && 5 == d.get_q());
rational e("3/15");
assert(1 == e.get_p() && 5 == e.get_q());
rational f("315");
assert(315 == f.get_p() && 1 == f.get_q());
rational g(std::move(e));
assert(1 == e.get_p() && 1 == e.get_q());
assert(1 == g.get_p() && 5 == g.get_q());
}
Сноски
*Ссылки отличаются от указателей тем, что они уже разыменованы в момент инициализации; если для какого-то int
a ссылка создается с помощью &a
, то для int &
a она уже ведет себя как int
. Другими словами, если к данным указателя вы обращаетесь через оператор разыменования *, для ссылок это делать не нужно, они ведут себя как обычные переменные.
Источники
- “Restrictions on default arguments (C++ only)”, IBM Documentation, ibm.com/docs/
- Компилятору позволяется самому выбирать между копированием и переносом в ситуациях, когда переменная не отмечена ключевым словом
volatile
или не является аргументом функции; Polacek,M., “Understanding when not to std::move in C++”, RedHat Developer Hub, developers.redhat.com/blog/
- Это название исторически закрепилось за анонимными значениями в коде, так как они всегда находятся справа от оператора присваивания (отсюда и название, right-hand value - R-value). Примером такого значения может быть любое константное число (
int x = 5;
), которое указывает на присваиваемое в переменную первоначальное значение. В С++ переменные тоже могут обладать свойствами R-значений, в этом случае их называют Х-значениями; “Value categories”, C++ Reference, en.cppreference.com
- Точнее, функция move возвращает R-значение без вызова соответствующего конструктора копирования; Hinnant, H.E. et al., “A Brief Introduction to Rvalue References”, Open STD, open-std.org
Copyright © 2022 Брынзан, Л.В.
GNU General Public License
“Commons Clause” License Condition v1.0
GPL Attribution-ShareAlike 4.0 International