« К оглавлению
‹ Сокрытие данных и инкапсуляция ↟ Вверх Перегрузка функций ›

Конструкторы

Понятие конструктора существует во многих языках программирования и также является абстракцией, основное предназначение которой – абстрагироваться от процедуры конструирования объекта в программе. Если пробежаться глазами по примерам из предыдущих частей, можно заметить постепенный переход от конструирования объектов типа rational вручную к делегированию этого конструирования специальной функции (cons). Сделав это вы установили конкретные правила конструирования объектов. Таким образом любой программист, который в будущем захотел бы воспользоваться типом данных rational вынужден бы был создавать переменные посредством функции cons.

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


return cons(r.p, r.q);

Было бы логичней если бы оно выглядело, например, так:


return rational(r);

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

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

Существуют и другие типы конструкторов. Например, вы могли бы написать такую функцию, которая создает объект типа rational на основании переданной строки определенного вида (для строки 32 создать объект с полем p равным 3, и полем q равным 2). Или, как упоминалось ранее, можно было бы написать функцию, которая создает рациональные числа, в которых значения полей p и q задаются по умолчанию в том случае, если программист при создании объекта не задал их явно (например, чтобы исключить возможность появления 0 в знаменателе для неинициализированного q).

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


rational(int x, 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, int x, 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);

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

Этот объект передается как указатель, для удобства названный this, что намекает на природу изменяемого объекта. Слово “этот” указывает на переменную, которая использовалась для вызова функции (то есть, в контексте функции ее вызвал “этот объект”). В примере выше – это адрес переменной b, соответственно b.p примет значение 20, а b.q50. Другими словами, если вне класса к полям объекта надо обращаться через оператор прямого доступа, применяемый к его имени в программе, то внутри класса – через оператор косвенного доступа, применяемый к его адресу в программе. С этого момента функции, объявленные внутри класса, которые содержат неявный аргумент this, мы будем называть методами.

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

Конкретней, для типа rational следующая запись вызовет конструктор по умолчанию.


rational a;

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

Первый способ заключается в предварительной инициализации полей внутри класса.


class rational {
private:
    int p{1};
    int q{1};
};

Используя инициализаторы (фигурные скобки после имени поля с данными) можно задать первоначальные значения для всех переменных такого типа. Это делается еще до вызова конструктора. Так можно гарантировать правильные значения для всех полей во избежание странных ошибок в дальнейшем.

Другой способ заключается в задании значений по умолчанию для одного из конструкторов с параметрами.


class rational {
public:
    rational(int x = 1, int y = 1) {
        assert(y != 0);
        if(y < 0) {
            this->p = -x;
            this->q = -y;
        }
        else {
            this->p = x;
            this->q = y;
        }
        if(x != 0) reduce(*this);
    }
private:
    int p;
    int q;
};        

Строка rational(int x = 1, int y = 1) использует свойство функций С++ устанавливать значения по умолчанию для параметров (соблюдая определенные правила [IBM]). У этого свойства есть интересные последствия для конструкторов.


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());

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

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


class rational {
public:
    rational() = delete;
    rational(int x, int y) {
        assert(y != 0);
        if(y < 0) {
            this->p = -x;
            this->q = -y;
        }
        else {
            this->p = x;
            this->q = y;
        }
        if(x != 0) reduce(*this);
    }
private:
    int p;
    int q;
};

Здесь появляется новое ключевое слово delete (не путать с оператором delete [Whitney(a)]). В данном случае оно указывает на то, что конструктор без параметров для этого типа использовать нельзя. Если теперь попробовать создать переменную без явной инициализации: 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 и без вашей помощи. Для составных типов данных вроде массивов оставлять копирование на совести компилятора не следует. Для данного случая конструктор копирования мог бы выглядеть следующим образом.


class rational {
public:
    rational(const rational &other) {
        this->p = other.p;
        this->q = other.q;
    }
private:
    int p{1};
    int q{1};
};

Как вы уже знаете, в конструктор передается аргумент для скрытого параметра rational *this, и явно в пару к нему заявляется параметр – ссылка1 на объект (указатель, который не может принимать значения NULL или nullptr) такого же типа. Таким образом, необходимо поочередно скопировать значения всех полей второго аргумента (other в примере) в первый (this). Существует и другая форма записи, использующая список инициализации.


class rational {
public:
    rational(const rational &other) : p(other.p), q(other.q) {}
private:
    int p{1};
    int q{1};
};

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


 

Упражнения

1

Используя примеры из этой части необходимо написать конструктор, который создает объект типа rational на основании переданной строки определенного вида (например, для строки “3/2” создать объект с полем p равным 3, и полем q равным 2).

2

Конструктор переноса является еще одним “каноническим конструктором”, который в С++ принято реализовывать из соображений быстродействия программ. Останавливаться на нем очень подробно сейчас не стоит, но познакомиться с принципами его работы полезно. Работает он следующим образом. Для значений особого типа такой конструктор не копирует передаваемый ему объект, а “крадет” существующие в памяти значения, переписывая адреса этих значений на свой объект. Таким образом переданный в этот конструктор объект остается без действительных значений, так как все они были перенесены в другой объект. В зависимости от конкретной реализации языка С++ это может происходить автоматически2. В большинстве же случаев программисты самостоятельно отмечают переменные, которые они хотят перенести. Такие переменные принято называть ссылками на R-значения3 и в коде отмечать как && – ссылка на ссылку.

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


 

Сноски

1Ссылки в С++ отличаются от указателей в С тем, что они уже разыменованы в момент инициализации; если для какого-то int a ссылка создается с помощью оператора &a, то для int& a объект уже ведет себя как int. Другими словами, если к данным указателя вы обращаетесь через оператор разыменования *, для ссылок это делать не нужно, они ведут себя как обычные значения.

2Компилятору позволяется самому выбирать между копированием и переносом в ситуациях, когда переменная не отмечена ключевым словом volatile или не является аргументом функции [Polacek].

3Это название исторически закрепилось за анонимными значениями в коде, так как они всегда находятся справа от оператора присваивания (отсюда и название, right-hand value – R-value). Примером такого значения может быть любое константное число (int x = 5), которое указывает на присваиваемое в переменную первоначальное значение. В С++ переменные тоже могут обладать свойствами R-значений, в этом случае их называют Х-значениями [CPP(a)].


 

Copyright © 2023 Брынзан, Л. В.
GNU General Public License
“Commons Clause” License Condition v1.0
GPL Attribution-ShareAlike 4.0 International