В предыдущей части я был приятно удивлён тем, что для создания экземпляра класса на стеке не требуется оператор 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, так что удивляться нечему.
POD
POD расшифровывается как Plain Old Data (простые старые данные) и обозначает данные чистого C: примитивные типы и структуры. Класс считается POD, когда он состоит только из POD:
В данном примере класс состоит из двух свойств x, y, занимает 8 байт памяти, и поэтому неотличим от структуры с полями x, y, тоже занимающей 8 байт памяти. Более того, class MyClass это и есть структура struct MyClass. В C++ структуры и классы это буквально одно и то же, и основное отличие заключается в том, что поля структуры по умолчанию публичные (наследие C), а свойства класса по умолчанию приватные. Так что, описывая класс, можно даже использовать слово struct:
Имплицитные и эксплицитные конструкторы
Проще говоря, неявные и явные конструкторы.
Конструкторы, которые мы пишем руками – явные. Конструкторы, которые мы не пишем, за нас пишет компилятор, и они, соответственно, неявные. У каждого класса должен быть явный или неявный конструктор.
Например, вот явный конструктор, написанный руками:
А вот из чего получится неявный:
Чтобы присвоить значение 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*, так что по минимуму заведём свойство с этим указателем.
Перейдём сразу к созданию объекта с инициализацией его значения какой-то строкой, например "Hello World".
Для этого напишем конструктор, который принимает один параметр типа char*:
Опять же опуская всё лишнее, конструктор просто сохраняет переданный указатель в свойстве str. И вот объект как-то инициализирован.
Чтобы создать объект с нужным строковым значением, нужно явно вызвать конструктор с параметром. Через вывод на печать убедимся, что этот конкретный конструктор сработал:
И вот бы сейчас остановиться, но тут нам в голову приходит свежая мысль, что неплохо бы сделать всё "как у людей":
String s = "Hello World";
Такая операция должна вызвать ошибку, так как мы объекту типа String пытаемся присвоить значение типа char*, так оно в C не работает.
Но что делает C++? Он перегружает (наделяет новым смыслом) оператор "=" следующим образом:
Если создаваемому объекту присваивается значение типа char*, и если у этого класса есть конструктор с одним параметром типа char*, то вызывается этот конструктор с этим значением.
Поэтому запись String s = "Hello World" это на самом деле вызов конструктора String s("Hello World").
Название String с присваиванием строки выглядят интуитивно понятно, как будто так и должно быть. Но можно сделать любой произвольный класс с любыми произвольными свойствами:
и точно так же его инициализировать:
Смотрите, на этот раз мы объекту произвольного класса MyClass присвоили значение 5, что выглядит абсурдно. Но работает, потому что в классе есть конструктор с одним параметром типа int.
Это конструктор неявного преобразования, то есть он преобразует число 5 в объект MyClass.
Чтобы присвоить одной строковой переменной значение другой, то есть вот так:
String s1 = "Hello World";
String s2 = s1;
Нужно добавить конструктор с параметром типа String:
Опять же пропускаем детали вроде выделения памяти и непосредственно копирования строки. Этим должен заниматься конструктор, но для примера это неважно.
Такой конструктор называется конструктором копирования. Он служит для копирования данных из одного объекта в другой, и поэтому в качестве аргумента принимает объект того же самого типа.
Теперь, если мы напишем String s1 = "Hello World", то вызовется конструктор, у которого параметр типа char*, а если напишем String s2 = s1, то вызовется конструктор копирования, у которого параметр типа String.
Как видим, эти конструкторы вызываются неявно, что приводит к следующим проблемам:
Можно банально присвоить не то и не узнать об этом
Повторю абсурдный пример:
int x = 5;
MyClass test = x;
Возможно, я хотел вызвать конструктор MyClass(int). Но также возможно, что я случайно написал x вместо чего-то другого. А компилятор C++ увидел, что x это int, и что есть конструктор с типом параметра int, и молча его вызвал. Ошибку он не выдал, потому что с его точки зрения ошибки нет. А я эту ошибку найду, если повезёт, через неделю.
Понимаете, насколько это страшно?
Усугубляется это тем, что есть не только свои, но и чужие классы. И мы должны знать, какие точно конструкторы неявного преобразования и копирования в них есть, чтобы вот так случайно, будучи абсолютно не в курсе, их не вызвать.
Рассмотрим ещё более плохой пример, который я адаптировал отсюда. Вот у нас есть два класса A и B:
Класс B, как видно, можно сконструировать с передачей ему объекта класса A. Далее у нас есть функция test(), которая принимает аргумент типа B:
Создаём объект класса 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:
В этом случае можно будет делать только так (явно):
String s1("Hello World");
String s2(s1);
и нельзя будет делать так (неявно):
String s1 = "Hello World";
String s2 = s1;
Мысль не моя, но я полностью с ней согласен: все конструкторы должны быть разрешены только для явного использования, во избежание проблем. Неявные вызовы – зло. Перегрузка операторов – зло. Не жили хорошо, нехер и начинать.
Читайте дальше: