До сих пор все данные были россыпью переменных. Структура – способ сказать компилятору, что эти данные связаны: они лежат рядом, копируются вместе и имеют общее имя.
Структура – составной тип
struct Point {
double x;
double y;
};
Point p; // поля не инициализированы: там мусор
Point q = {1.5, 2.5}; // инициализация списком
Point r{1.5, 2.5}; // то же самое, современный синтаксис
std::cout << q.x; // доступ через точку
Объявление структуры не выделяет память – это описание типа. Память занимает экземпляр.
Структура – полноценный тип: её можно передавать в функции, возвращать, класть в массив, делать полем другой структуры.
struct Segment {
Point from;
Point to;
};
Segment s{{0, 0}, {3, 4}};
std::cout << s.to.x; // 3
Размер и выравнивание
sizeof структуры – не всегда сумма размеров полей:
struct Bad { char a; int b; char c; }; // обычно 12 байт
struct Good { int b; char a; char c; }; // обычно 8 байт
Процессор читает данные эффективнее, когда адрес кратен размеру типа, поэтому компилятор вставляет между полями выравнивающие пропуски (padding). Порядок полей на это влияет: если сложить мелкие поля рядом, дырок будет меньше.
В прикладном коде за этим не гоняются – читаемость важнее восьми байт. Но в структуре, которой создаются миллионы экземпляров, или в той, что описывает формат данных на диске или в сети, порядок полей становится вопросом по существу. Во втором случае к нему добавляется ещё и порядок байт, и тогда структуру не отображают на буфер напрямую, а разбирают полями явно.
Указатель на структуру
Point p{1, 2};
Point* ptr = &p;
std::cout << (*ptr).x; // работает, но скобки обязательны и мешают
std::cout << ptr->x; // то же самое, читаемо
-> – сокращение для (*p).. Правило простое: объект и ссылка – точка, указатель – стрелка.
Передача в функцию подчиняется тем же правилам, что и для любого другого типа:
void ByValue(Point p); // копия всей структуры
void ByRef(Point& p); // оригинал, можно менять
void ByConstRef(const Point& p); // оригинал, менять нельзя
void ByPtr(Point* p); // может быть nullptr
Массив-поле копируется вместе со структурой, хотя отдельно взятый массив в функцию копироваться не стал бы. А вот поле-указатель копируется как адрес: у копии и оригинала он будет один, и данные окажутся общими. Это поверхностное копирование – источник половины ошибок в классах, управляющих памятью. К нему мы вернёмся, когда дойдём до конструктора копирования.
Методы
Функция, работающая только с одной структурой, логично живёт внутри неё:
struct Point {
double x;
double y;
double Length() const {
return std::sqrt(x * x + y * y);
}
void Move(double dx, double dy) {
x += dx;
y += dy;
}
};
Point p{3, 4};
std::cout << p.Length(); // 5
p.Move(1, 1);
Внутри метода поля доступны просто по имени. Никакой магии здесь нет: метод – это обычная функция, которой компилятор добавляет скрытый первый параметр – указатель на объект, у которого её вызвали.
this
Этот скрытый параметр называется this:
struct Point {
double x;
double y;
void SetX(double x) {
this->x = x; // без this поле было бы затенено параметром
}
Point& Move(double dx, double dy) {
x += dx;
y += dy;
return *this; // возвращаем сам объект – можно строить цепочки
}
};
p.Move(1, 0).Move(0, 1);
Писать this-> без необходимости не нужно – поля и так видны. Он нужен в двух случаях: когда
имя параметра совпадает с именем поля и когда нужен сам объект – вернуть его или сравнить адрес.
Константные методы
Метод, помеченный const, обещает не менять объект:
double Length() const; // не меняет
void Move(double, double); // меняет
Обещание проверяет компилятор: попытка присвоить полю внутри const-метода – ошибка.
Практическое следствие важнее формального: у константного объекта можно вызвать только константные методы.
const Point p{3, 4};
p.Length(); // ок
p.Move(1, 1); // ошибка компиляции
Поскольку в функции объекты почти всегда приходят как const T&, метод без const там
недоступен. Поэтому правило: всё, что не меняет объект, помечайте const сразу. Дописать
потом – значит пройтись по всей цепочке вызовов.
Объявление и определение
Метод можно определить прямо в структуре или вынести наружу – тогда в определении указывают, к какому типу он относится:
// point.hpp
struct Point {
double x;
double y;
double Length() const; // объявление
};
// point.cpp
double Point::Length() const { // определение
return std::sqrt(x * x + y * y);
}
Разделение нужно по той же причине, что и для обычных функций: заголовок описывает интерфейс и подставляется всем, кто им пользуется, а реализация компилируется один раз. Правку тела метода, определённого в заголовке, оплачивают пересборкой все, кто этот заголовок включает.
Короткие методы всё же обычно оставляют в заголовке – чтобы компилятор мог их встроить. Разница между «встроить» и «вызвать» для метода из двух строк бывает кратной.