struct vs class: инкапсуляция и доступ по умолчанию
1. struct и class: доступ и инкапсуляция
Когда вы только начинаете программировать, кажется естественным: есть данные — значит делаем struct с полями, и всё. Это хороший старт. Но по мере роста программы возникает неприятная бытовуха: один кусок кода меняет поле напрямую, второй забывает проверить диапазон, третий считает, что поле «точно не пустое», и в итоге программа превращается в сериал «Следствие вели…».
В этот момент появляется полезная мысль: давайте запретим внешнему коду трогать «внутренности» объекта как попало и дадим доступ только через понятные методы. Эта идея и называется инкапсуляцией. И технически она в C++ упирается не в магию, а в две вещи: секции public/private и то, какой доступ стоит по умолчанию у struct и class.
Небольшая ремарка про контекст: мы пишем на современном C++23 (ориентируемся на C++23), который формализуется рабочими черновиками стандарта.
struct: public по умолчанию
struct в C++ — это способ объявить собственный тип, в котором удобно хранить несколько связанных значений. В ранних главах курса мы использовали такие структуры как «модель данных»: поля открыты, их можно читать и менять напрямую. Это отлично подходит для простых объектов, где нет сложных правил корректности.
Ключевой момент: внутри struct все члены по умолчанию имеют доступ public. То есть внешний код может обращаться к полям напрямую. Это не «плохое» поведение — это просто настройка по умолчанию.
Мини‑пример с точкой на плоскости:
#include <iostream>
struct Point {
int x{};
int y{};
};
int main() {
Point p{10, 20};
p.x = 15; // OK: public по умолчанию
std::cout << p.x << ' ' << p.y << '\n'; // 15 20
}
Обратите внимание: после закрывающей } стоит ; — он обязателен. И это не «специальный» тип: это такой же пользовательский тип, как и class.
class: private по умолчанию
Слово class звучит так, будто сейчас начнётся «высшая математика, ООП, паттерны и UML на стене». На самом деле нет: class — это тот же механизм пользовательского типа, что и struct. Можно объявлять поля, можно объявлять методы.
Разница, которая важна сегодня, ровно одна: в class члены по умолчанию имеют доступ private.
Пример с простым счётчиком:
#include <iostream>
class Counter {
int value_ = 0; // private по умолчанию
public:
void inc() { ++value_; }
int value() const { return value_; }
};
int main() {
Counter c;
c.inc();
std::cout << c.value() << '\n'; // 1
// c.value_ = 42; // ошибка компиляции: private
}
Это выглядит как мелочь, но по факту это фундамент: теперь состояние объекта нельзя сломать случайным присваиванием из внешнего кода. А значит, правила корректности можно хранить «в одном месте» — внутри методов.
Разница только в умолчаниях
На этом месте часто рождается миф: будто struct — это «простая структура», а class — «настоящий объект». В C++ это неверно. struct и class — почти одно и то же: можно писать методы и там, и там; можно делать private и там, и там; можно смешивать секции доступа и там, и там.
Разница, которую стоит запомнить железобетонно:
- в struct по умолчанию public
- в class по умолчанию private
Для ясности — небольшая таблица:
| Что сравниваем | |
|
|---|---|---|
| Объявляет пользовательский тип | да | да |
| Можно иметь поля и методы | да | да |
Можно использовать / |
да | да |
| Доступ по умолчанию | |
|
И ещё одна практическая мелочь: точка с запятой после объявления типа обязательна в обоих случаях:
struct A { int x{}; }; // ; нужна
class B { int y{}; }; // ; нужна
Инкапсуляция в struct тоже возможна
Когда кто-то говорит «мы используем struct, значит инкапсуляции нет», он путает стиль и возможности языка. Инкапсуляция в C++ делается через public/private, а не через само слово class.
Пример struct с приватным полем:
#include <iostream>
struct Box {
private:
int value_ = 0;
public:
void set(int v) { value_ = v; }
int value() const { return value_; }
};
int main() {
Box b;
b.set(7);
std::cout << b.value() << '\n'; // 7
}
Тогда зачем вообще нужно два слова? Ответ не в технике, а в коммуникации с читателем. Частая договорённость в командах такая: если это «просто данные без хитрых правил» — пишем struct; если это «объект с правилами, который надо аккуратно использовать» — пишем class. Это не закон языка, а социальный сигнал: «Здесь есть контракт».
Мини‑тест: разница проявляется именно из‑за доступа
Короткий фрагмент, который помогает закрепить: разница не в «магии», а в доступе.
#include <iostream>
#include <string>
struct UserStruct {
std::string name = "anonymous";
};
class UserClass {
std::string name_ = "anonymous";
public:
const std::string& name() const { return name_; }
};
int main() {
UserStruct a;
a.name = "Alice"; // OK
std::cout << a.name << '\n'; // Alice
UserClass b;
// b.name_ = "Bob"; // ошибка: private
std::cout << b.name() << '\n'; // anonymous
}
Если вы запомните это различие «на автомате», вы резко сократите число ситуаций, когда вы смотрите в монитор с лицом «почему оно не доступно, я же ничего такого не делаю».
3. Инкапсуляция на примере Account
Публичные поля превращают объект в «сам себе QA»
Представьте, что у вас есть модель банковского счёта. Наивный (но очень частый) вариант: сделать struct с балансом и менять его напрямую. Работает? Работает. До первого бага.
#include <iostream>
struct Account {
int balance_cents = 0;
};
int main() {
Account a;
a.balance_cents += 500;
a.balance_cents -= 1'000'000; // ой
std::cout << a.balance_cents << '\n'; // -999500
}
С точки зрения компилятора всё отлично. С точки зрения здравого смысла — счёт «ушёл в минус» на сумму, которую мы даже не проверяли. Можно сказать: «ну программист должен быть аккуратным». Да. И пользователь кофемашины тоже должен чистить её каждый день. Но почему-то производители всё равно ставят автоматическую промывку.
Инкапсуляция — это ваша «автоматическая промывка»: мы закрываем поле и делаем методы, которые не дадут выполнить бессмысленную операцию.
#include <iostream>
class Account {
int balance_cents_ = 0;
public:
void deposit(int cents) {
if (cents > 0) balance_cents_ += cents;
}
bool withdraw(int cents) {
if (cents <= 0) return false;
if (cents > balance_cents_) return false;
balance_cents_ -= cents;
return true;
}
int balance_cents() const { return balance_cents_; }
};
int main() {
Account a;
a.deposit(500);
a.withdraw(1'000'000); // не получилось, но объект жив
std::cout << a.balance_cents() << '\n'; // 500
}
Мы пока не обсуждаем исключения, сложные конструкторы и «красивую архитектуру». Мы просто сделали так, чтобы объект нельзя было привести в бредовое состояние одним присваиванием.
Схема: кто теперь имеет право менять состояние объекта
Полезно один раз увидеть, что происходит при переходе от «публичных полей» к «публичным методам». В первом варианте любой код может поменять поле. Во втором — только код внутри класса (его методы) может менять приватные поля.
flowchart TD
Outside["Внешний код"] -->|вызов public методов| Obj["Объект класса"]
Obj -->|читает/меняет| Private["private поля"]
Outside -.->|напрямую менять нельзя| Private
Смысл этой схемы простой: правила проверки живут внутри методов, а значит, не размазываются по всему проекту. Если правило изменится (например, «нельзя снимать меньше 10 центов»), вы меняете одно место, а не двадцать.
4. Пример из проекта: Task как более осмысленный тип
Чтобы не говорить абстрактно, представим, что в прошлых главах курса мы делали консольный мини‑планировщик задач: храним задачи в std::vector, выводим список, помечаем выполненными. Раньше модель задачи могла выглядеть так:
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
Проблема такого подхода не в том, что он «не ООП». Проблема в другом: внешний код может сделать вот так — и компилятор не возразит:
Task t;
t.id = -5; // «задача номер минус пять»
t.title = ""; // пустой заголовок
t.done = true; // выполнено… но что именно?
Если у вас нет правил, это нормально. Но если вы хотите, чтобы задача всегда имела корректный id и непустой заголовок, удобнее положить эти правила внутрь типа.
Сделаем первый шаг (без конструкторов — они будут в следующих лекциях): заведём class Task и дадим минимальные методы.
#include <string>
class Task {
int id_ = 0;
std::string title_ = "untitled";
bool done_ = false;
public:
bool set_id(int id) {
if (id <= 0) return false;
id_ = id;
return true;
}
bool rename(const std::string& title) {
if (title.empty()) return false;
title_ = title;
return true;
}
};
Сейчас это выглядит как «мы просто спрятали поля и добавили шум». Но обратите внимание на эффект: объект нельзя привести в некорректное состояние случайно. Если set_id вернул false, значит состояние не поменялось — и это уже зачаток дисциплины.
Добавим методы чтения (чуть-чуть, чтобы можно было печатать):
#include <string>
class Task {
int id_ = 0;
std::string title_ = "untitled";
bool done_ = false;
public:
int id() const { return id_; }
const std::string& title() const { return title_; }
bool done() const { return done_; }
void mark_done() { done_ = true; }
};
Сразу видно стиль, который мы будем развивать дальше: «читать можно безопасно, менять — через контролируемые операции».
И да, суффикс _ в приватных полях — это не требование языка, а полезная привычка: когда вы видите done_, мозг автоматически понимает «внутреннее поле, руками не трогать».
5. Как выбирать между struct и class
Когда вы только учитесь, хочется получить железное правило: «всегда делай так». В реальной разработке правило чаще звучит так: «делай понятно и безопасно».
Обычно struct отлично подходит для простых переносимых данных, где нет жёстких ограничений и инвариантов. Например, Point{ x, y } или Rgb{ r, g, b } — там логика минимальна, а прямой доступ к полям делает код короче и читабельнее.
class чаще выбирают, когда у объекта есть правила корректности и вы хотите, чтобы компилятор помогал вам эти правила не нарушать. Как только вы ловите себя на мысли «поле нельзя менять как попало» или «значение должно быть только в диапазоне» — это кандидат на private и публичные методы.
При этом важно помнить: переход на class не делает программу автоматически правильной. Это инструмент, который помогает выразить контракт. Если вы закроете поле, но сделаете сеттер без проверок, то вы просто добавите букв, не добавив смысла. Смысл появляется, когда методы действительно защищают состояние.
6. Типичные ошибки
Ошибка №1: забыть public: в class и удивляться, что «ничего не работает».
Это самая частая ситуация у новичков: вы пишете class, добавляете метод, а потом main говорит «private». Причина проста: в class всё private по умолчанию. Лечится не заклинаниями, а строкой public: перед тем, что вы хотите открыть наружу.
Ошибка №2: думать, что struct «не умеет» инкапсуляцию.
Иногда люди воспринимают struct как «бедный родственник класса». Но private: и public: работают одинаково и там, и там. Разница только в умолчаниях. Если вам удобно объявить тип словом struct, но хочется спрятать часть деталей — прячьте, язык не против.
Ошибка №3: делать class, но оставлять все поля public, потому что «так быстрее».
Это выглядит как компромисс, но обычно это проигрыш: вы берёте «сигнал читателю» (слово class), но не используете его смысл. В итоге следующий разработчик ожидает инкапсуляцию и осторожное API, а получает свободный доступ к полям. Если поля должны быть открыты — честнее написать struct.
Ошибка №4: путать «доступ по умолчанию» и «права методов».
Иногда кажется, что если поле private, то и методы не смогут его менять. Наоборот: методы этого же класса имеют доступ к приватным полям своего объекта. Именно поэтому инкапсуляция работает: внешний код не может, а методы — могут, и делают это по правилам.
Ошибка №5: забыть, что после } в объявлении типа нужен ;.
Компилятор в таком случае выдаёт сообщение, которое новичку может показаться загадочным. Запоминаем шаблон руками: class X { ... }; и struct Y { ... };. Да, точка с запятой здесь — как крышка от бутылки: вроде мелочь, но без неё всё разливается.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ