Найти в Дзене
ZDG

От C к C++: Оператор new и всякие-разные конструкторы

Оглавление

В предыдущей части я был приятно удивлён тем, что для создания экземпляра класса на стеке не требуется оператор new, как в других языках:

Всё происходит как обычное объявление переменной нужного типа, и объект уже создан (на стеке), и даже вызван его конструктор.

На третий день Зоркий Глаз увидел, что в C++ всё-таки используется new:

MyClass* test = new MyClass();

Можно писать new MyСlass со скобками или без скобок, но про это позже. А пока про new.

На деле оператор new выделяет память под объект из кучи. То есть под капотом там обычный malloc(), который выделил, к примеру, 8 байт памяти и вернул указатель на них. Именно поэтому переменная, которая принимает созданный через new объект, должна быть указателем: MyClass* test.

И... выделенную память надо не забывать потом освобождать,

для чего используется:

delete test;

что в чистом C аналогично free(test);

Я хочу посмотреть, что будет, если самостоятельно выделить память под объект класса. Будет ли это вообще работать?

MyClass* test = (MyClass*) malloc(sizeof(MyClass));

Работает, но никакие конструкторы не вызываются и выделенная память не инициализируется значениями по умолчанию. Мы имеем переменную-указатель на объект класса MyClass, которой присваиваем адрес якобы уже созданного объекта. Поэтому никакой инициализации и не происходит.

Указателю на объект класса можно присвоить любой адрес с любыми данными, но это C, так что удивляться нечему.

-2

POD

POD расшифровывается как Plain Old Data (простые старые данные) и обозначает данные чистого C: примитивные типы и структуры. Класс считается POD, когда он состоит только из POD:

-3

В данном примере класс состоит из двух свойств x, y, занимает 8 байт памяти, и поэтому неотличим от структуры с полями x, y, тоже занимающей 8 байт памяти. Более того, class MyClass это и есть структура struct MyClass. В C++ структуры и классы это буквально одно и то же, и основное отличие заключается в том, что поля структуры по умолчанию публичные (наследие C), а свойства класса по умолчанию приватные. Так что, описывая класс, можно даже использовать слово struct:

-4

Имплицитные и эксплицитные конструкторы

Проще говоря, неявные и явные конструкторы.

Конструкторы, которые мы пишем руками – явные. Конструкторы, которые мы не пишем, за нас пишет компилятор, и они, соответственно, неявные. У каждого класса должен быть явный или неявный конструктор.

Например, вот явный конструктор, написанный руками:

-5

А вот из чего получится неявный:

-6

Чтобы присвоить значение 0 свойствам x и y, компилятор создаст конструктор, аналогичный написанному выше.

Если смотреть на машинный код, то после резервирования памяти должен вызываться конструктор, который что-то сделает с этой памятью. Однако для объектов, создаваемых на стеке, я таких вызовов не видел. Инициализация происходит инлайн, то есть записью значений в свойства объекта прямо по месту. Возможно, если инициализация будет очень длинной, компилятор решится на отдельно стоящий конструктор. А вот при использовании new вызов конструктора происходит всегда, даже если там ничего тяжёлого не делается.

Также конструктор будет совсем отсутствовать в машинном коде, если не требуется никакая инициализация свойств класса (ну это логично).

Вернёмся к вариантам записи new без скобок:

MyClass* test = new MyClass;

и со скобками:

MyClass* test = new MyClass();

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

MyClass* test = new MyClass(1, 2);

Получается, если параметров нет, то скобки можно не ставить, и будет вызван конструктор по умолчанию – практика, распространённая в разных языках, которую я никогда не поддерживал, потому что считаю, что синтаксис должен быть однозначный, и если вызывается метод, значит и скобки должны быть.

Тёмная сторона C++: Конструкторы неявного преобразования и копирования

Здесь начинается очень кривая дорожка, вдоль которой мёртвые с косами стоят.

C++ добавил в C много "удобств". К примеру, в C работа со строками очень осложнена. Чтобы создать строку, надо выделить память. Чтобы скопировать содержимое строки в другую строковую переменную, нужно под другую переменную тоже выделить память. Всё это надо делать руками.

И вот наконец в C++ строки стали работать как в "нормальных" языках. Их можно просто присваивать:

string s1 = "Hello World";
string s2 = s1;

Чтобы понять, откуда что берётся, реализуем собственный класс String очень схематично, без учёта мелочей.

Очевидно, это будет обёртка над простым указателем char*, так что по минимуму заведём свойство с этим указателем.

-7

Перейдём сразу к созданию объекта с инициализацией его значения какой-то строкой, например "Hello World".

Для этого напишем конструктор, который принимает один параметр типа char*:

-8

Опять же опуская всё лишнее, конструктор просто сохраняет переданный указатель в свойстве str. И вот объект как-то инициализирован.

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

-9

И вот бы сейчас остановиться, но тут нам в голову приходит свежая мысль, что неплохо бы сделать всё "как у людей":

String s = "Hello World";

Такая операция должна вызвать ошибку, так как мы объекту типа String пытаемся присвоить значение типа char*, так оно в C не работает.

Но что делает C++? Он перегружает (наделяет новым смыслом) оператор "=" следующим образом:

Если создаваемому объекту присваивается значение типа char*, и если у этого класса есть конструктор с одним параметром типа char*, то вызывается этот конструктор с этим значением.

Поэтому запись String s = "Hello World" это на самом деле вызов конструктора String s("Hello World").

Название String с присваиванием строки выглядят интуитивно понятно, как будто так и должно быть. Но можно сделать любой произвольный класс с любыми произвольными свойствами:

-10

и точно так же его инициализировать:

-11

Смотрите, на этот раз мы объекту произвольного класса MyClass присвоили значение 5, что выглядит абсурдно. Но работает, потому что в классе есть конструктор с одним параметром типа int.

Это конструктор неявного преобразования, то есть он преобразует число 5 в объект MyClass.

Чтобы присвоить одной строковой переменной значение другой, то есть вот так:

String s1 = "Hello World";
String s2 = s1;

Нужно добавить конструктор с параметром типа String:

-12

Опять же пропускаем детали вроде выделения памяти и непосредственно копирования строки. Этим должен заниматься конструктор, но для примера это неважно.

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

Теперь, если мы напишем String s1 = "Hello World", то вызовется конструктор, у которого параметр типа char*, а если напишем String s2 = s1, то вызовется конструктор копирования, у которого параметр типа String.

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

Можно банально присвоить не то и не узнать об этом

Повторю абсурдный пример:

int x = 5;
MyClass test = x;

Возможно, я хотел вызвать конструктор MyClass(int). Но также возможно, что я случайно написал x вместо чего-то другого. А компилятор C++ увидел, что x это int, и что есть конструктор с типом параметра int, и молча его вызвал. Ошибку он не выдал, потому что с его точки зрения ошибки нет. А я эту ошибку найду, если повезёт, через неделю.

Понимаете, насколько это страшно?

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

Рассмотрим ещё более плохой пример, который я адаптировал отсюда. Вот у нас есть два класса A и B:

-13

Класс B, как видно, можно сконструировать с передачей ему объекта класса A. Далее у нас есть функция test(), которая принимает аргумент типа B:

-14

Создаём объект класса A:

A a;
a.x = 5;

И передаём его в функцию test():

test(a);

Так работать не должно, потому что функция ожидает тип B, а не A. Но компилятор по-модному, по-молодёжному хочет сделать преобразование типа.

(с) ШКЯ
(с) ШКЯ

Мол, передали A, но раз требуется B, можно ли как-то A динамически конвертировать в B? Оказывается, можно. Так как у B есть конструктор с параметром типа A, значит из A можно сделать B.

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

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

explicit спешит на помощь

Можно иметь конструкторы копирования, но запретить их неявно вызывать. Для этого служит ключевое слово explicit:

-16

В этом случае можно будет делать только так (явно):

String s1("Hello World");
String s2(s1);

и нельзя будет делать так (неявно):

String s1 = "Hello World";
String s2 = s1;

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

Читайте дальше:

Наука
7 млн интересуются