Патерни struct та межі застосування
Патерни struct та межі застосування
Від синтаксису до архітектури
Попередні три статті серії дали нам повний технічний інструментарій: ми вміємо оголошувати структури, ініціалізувати їх, передавати у функції, зберігати в масивах, вкладати одна в одну і будувати динамічні структури даних через само-референтні struct. Але синтаксис — лише засіб. Для того щоб писати якісний код, потрібно розуміти, як і коли застосовувати структури в архітектурному контексті.
Ця стаття присвячена чотирьом практичним патернам використання struct, а в кінці ми чесно розберемо питання, яке часто виникає: «Якщо структура може мати методи — чому б цим не скористатися?». Відповідь, яку дає сам Бйорн Страуструп, — не технічна, а семантична.
Патерн 1: Record (Запис)
Суть патерну
Record — найпростіший і найприродніший спосіб використання struct. Структура є чистим контейнером даних: набором пов'язаних полів, що разом описують одну сутність. Ніякої прихованої логіки, ніякого стану, що потрібно захищати. Всі поля публічні, значення встановлюються ззовні.
Саме так ми використовували Student і Point3D у попередніх статтях. Це — класичний Record.
#include <iostream>
#include <string>
#include <cstdint>
// Record: чистий опис сутності, всі поля публічні
struct Product
{
std::string name;
uint32_t sku; // Stock Keeping Unit — артикул
double price;
int quantity;
};
// Всі операції — через вільні функції
void printProduct(const Product& p)
{
std::cout << "[" << p.sku << "] " << p.name
<< " — " << p.price << " грн ("
<< p.quantity << " шт.)\n";
}
double totalValue(const Product& p)
{
return p.price * p.quantity;
}
bool isAvailable(const Product& p)
{
return p.quantity > 0;
}
int main()
{
Product laptop = { "Ноутбук Dell XPS 13", 100042, 42999.0, 5 };
Product mouse = { "Миша Logitech MX3", 200017, 2499.0, 0 };
printProduct(laptop);
std::cout << "Загальна вартість: " << totalValue(laptop) << " грн\n";
std::cout << "Є в наявності: " << (isAvailable(laptop) ? "Так" : "Ні") << "\n\n";
printProduct(mouse);
std::cout << "Є в наявності: " << (isAvailable(mouse) ? "Так" : "Ні") << "\n";
return 0;
}
Record — це «тип даних без секретів». У ньому немає і не повинно бути прихованого стану. Якщо ваша структура — це просто набір полів, що разом описують сутність, і над нею не потрібно підтримувати ніяких інваріантів — Record є правильним вибором.
Патерн 2: Value Object (Об'єкт-значення)
Що таке «семантика значення»
Уявіть дійсне число double. Якщо ви пишете double a = 3.14; double b = a; — ви отримуєте дві незалежні копії числа. Зміна b не впливає на a. Це і є семантика значення (value semantics): об'єкт поводиться як значення, копіюється при присвоєнні, і копія є повністю незалежною.
Структури у C++ за замовчуванням мають семантику значення: Student s2 = s1 — повна незалежна копія. Це відрізняє їх від вказівників, де Student* p2 = p1 дає два вказівники на один об'єкт.
Value Object — це struct, що представляє просте значення: геометричну точку, колір, грошову суму, дату. Value Object не має ідентичності — два об'єкти з однаковими полями вважаються рівними. Він завжди копіюється, ніколи не розділяє стан між копіями.
#include <iostream>
#include <cstdint>
#include <cmath>
struct Color
{
uint8_t r;
uint8_t g;
uint8_t b;
};
struct Money
{
long long cents; // зберігаємо у центах/копійках, уникаємо float
std::string currency;
};
// Value Object: порівняння через поля, не через адресу
bool colorsEqual(const Color& a, const Color& b)
{
return a.r == b.r && a.g == b.g && a.b == b.b;
}
Color blendColors(const Color& a, const Color& b)
{
return
{
static_cast<uint8_t>((a.r + b.r) / 2),
static_cast<uint8_t>((a.g + b.g) / 2),
static_cast<uint8_t>((a.b + b.b) / 2),
};
}
Money addMoney(const Money& a, const Money& b)
{
// Спрощення: вважаємо, що валюти однакові
return { a.cents + b.cents, a.currency };
}
void printColor(const Color& c)
{
std::cout << "rgb(" << (int)c.r << ", " << (int)c.g << ", " << (int)c.b << ")";
}
void printMoney(const Money& m)
{
std::cout << m.cents / 100 << "." << m.cents % 100 << " " << m.currency;
}
int main()
{
Color red = { 255, 0, 0 };
Color blue = { 0, 0, 255 };
Color blended = blendColors(red, blue);
std::cout << "Red: "; printColor(red); std::cout << "\n";
std::cout << "Blue: "; printColor(blue); std::cout << "\n";
std::cout << "Blended: "; printColor(blended); std::cout << "\n";
// Семантика значення: blended — повністю незалежна копія
Color copy = blended;
copy.r = 0; // не впливає на blended
std::cout << "Blended після copy.r=0: "; printColor(blended); std::cout << "\n";
Money salary = { 5000000, "UAH" }; // 50 000.00 UAH
Money bonus = { 750000, "UAH" }; // 7 500.00 UAH
Money total = addMoney(salary, bonus);
std::cout << "Зарплата: "; printMoney(salary); std::cout << "\n";
std::cout << "Бонус: "; printMoney(bonus); std::cout << "\n";
std::cout << "Разом: "; printMoney(total); std::cout << "\n";
return 0;
}
Зверніть рядок 60: після Color copy = blended; copy.r = 0 — blended.r залишається 127. Семантика значення гарантує повну незалежність копій.
Money і дійсні числа: у фінансових застосунках ніколи не зберігають суми у double або float — представлення чисел з рухомою точкою неточне. 0.1 + 0.2 ≠ 0.3 у double. Правильний підхід — зберігати суму у найменших одиницях (центах, копійках) як long long.Патерн 3: Result (Тип результату з помилкою)
Проблема: функція не може завжди успішно виконатись
Багато функцій можуть завершитись двома способами: успіхом або невдачею. Класичний підхід C — повертати спеціальне значення (-1, nullptr, 0) для позначення помилки. Але це неочевидно: яке значення — помилкове? І що саме пішло не так?
Патерн Result вирішує це елегантно: функція повертає структуру, що містить і результат, і ознаку успіху, і опис помилки.
#include <iostream>
#include <string>
// Result type: завжди чітко — успіх чи помилка і чому
struct ParseResult
{
bool ok; // true якщо парсинг успішний
int value; // результат (має сенс лише якщо ok == true)
std::string error; // опис помилки (має сенс лише якщо ok == false)
};
// Функція-парсер: не кидає виключень, повертає Result
ParseResult parseInt(const std::string& input)
{
if (input.empty())
return { false, 0, "Порожній рядок" };
// Перевірка, чи всі символи — цифри (або перший — знак)
int start = 0;
if (input[0] == '-' || input[0] == '+')
start = 1;
if (start == static_cast<int>(input.size()))
return { false, 0, "Лише знак без цифр" };
for (int i = start; i < static_cast<int>(input.size()); ++i)
{
if (input[i] < '0' || input[i] > '9')
return { false, 0, "Некоректний символ: '" + std::string(1, input[i]) + "'" };
}
return { true, std::stoi(input), "" };
}
void handleResult(const ParseResult& r, const std::string& input)
{
if (r.ok)
std::cout << "\"" << input << "\" → " << r.value << "\n";
else
std::cout << "\"" << input << "\" → Помилка: " << r.error << "\n";
}
int main()
{
handleResult(parseInt("42"), "42");
handleResult(parseInt("-17"), "-17");
handleResult(parseInt(""), "");
handleResult(parseInt("12a3"), "12a3");
handleResult(parseInt("+"), "+");
return 0;
}
Патерн Result змушує викликача явно перевірити r.ok перед використанням r.value. Це принципово відрізняється від «магічного» return -1 — читач коду одразу бачить, що функція може провалитися, і що саме пішло не так.
std::expected<T, E> — вбудований Result type. До C++23 патерн реалізується саме через struct, як показано вище.Патерн 4: Named Parameters (Іменовані параметри)
Проблема: функція з багатьма булевими параметрами
Розглянемо функцію сортування з параметрами поведінки:
// Яке сортування? Що означає true, false, true?
sortArray(arr, size, true, false, true);
Три булеві параметри — і жодного розуміння зі сторінки виклику, що вони означають. Це класична проблема stringly-typed або boolean-trap антипатерну.
Рішення — Named Parameters: обернути параметри у структуру з промовистими іменами полів:
#include <iostream>
#include <string>
struct SortOptions
{
bool ascending = true; // за зростанням (true) або спаданням (false)
bool ignoreCase = false; // ігнорувати регістр при порівнянні рядків
bool stable = false; // стабільне сортування (зберегти відносний порядок рівних)
};
struct SearchOptions
{
bool caseSensitive = true; // чутливість до регістру
bool wholeWord = false; // шукати ціле слово, не підрядок
int maxResults = 10; // максимальна кількість результатів
};
void sortDemo(const std::string* arr, int size, const SortOptions& opts)
{
std::cout << "Сортування: "
<< (opts.ascending ? "за зростанням" : "за спаданням") << ", "
<< (opts.ignoreCase ? "без урахування регістру" : "з урахуванням регістру") << ", "
<< (opts.stable ? "стабільне" : "нестабільне") << "\n";
// Реальна логіка сортування тут...
}
void searchDemo(const std::string& query, const SearchOptions& opts)
{
std::cout << "Пошук \"" << query << "\": "
<< (opts.caseSensitive ? "чутливо" : "нечутливо") << " до регістру, "
<< (opts.wholeWord ? "ціле слово" : "підрядок") << ", "
<< "макс. " << opts.maxResults << " результатів\n";
}
int main()
{
std::string names[] = { "Аліса", "боб", "Крістіна", "дмитро" };
// Явно і читабельно через designated initializers
SortOptions opts1 = { .ascending = true, .ignoreCase = true };
sortDemo(names, 4, opts1);
// Значення за замовчуванням для невказаних полів
SortOptions opts2 = { .ascending = false };
sortDemo(names, 4, opts2);
SearchOptions searchOpts = { .caseSensitive = false, .maxResults = 5 };
searchDemo("аліса", searchOpts);
return 0;
}
Завдяки designated initializers (C++20) і default member initializers код виклику стає самодокументованим: { .ascending = false } — одразу зрозуміло, що саме відрізняється від дефолтної поведінки. Ті поля, що не вказані, отримують значення за замовчуванням.
struct vs class: єдина технічна різниця
Перед фінальним розбором щодо методів — важливо розвіяти поширену оману: C++ не розрізняє struct і class на рівні можливостей мови. Єдина технічна різниця між ними — доступ за замовчуванням:
struct | class | |
|---|---|---|
| Доступ до членів за замовчуванням | public | private |
| Успадкування за замовчуванням | public | private |
| Може мати методи | ✅ | ✅ |
| Може мати конструктор | ✅ | ✅ |
| Може успадковувати | ✅ | ✅ |
| Може бути шаблоном | ✅ | ✅ |
Тобто технічно struct і class — повні синоніми, різняться лише дефолтним рівнем доступу. Будь-яку class можна записати як struct і навпаки — лише дописавши або видаливши public: / private:.
Методи у struct: технічно можливо
Оскільки struct і class технічно майже еквівалентні, у структуру можна додавати методи:
#include <iostream>
#include <string>
#include <cmath>
// Технічно коректно — але чи семантично правильно?
struct Point2D
{
double x;
double y;
// Метод всередині struct
double distanceTo(const Point2D& other) const
{
double dx = x - other.x;
double dy = y - other.y;
return std::sqrt(dx*dx + dy*dy);
}
void print() const
{
std::cout << "(" << x << ", " << y << ")";
}
};
int main()
{
Point2D a = { 0.0, 0.0 };
Point2D b = { 3.0, 4.0 };
a.print();
std::cout << " → ";
b.print();
std::cout << " : відстань = " << a.distanceTo(b) << "\n";
return 0;
}
Код компілюється. Код працює. І все ж це — порушення семантичної конвенції. Саме тому ця демонстрація розміщена в кінці статті, а не на початку.
Позиція Страуструпа: чому методи у struct — семантична помилка
Бйорн Страуструп, автор мови C++, у своїй книзі «The C++ Programming Language» (4-те видання) чітко артикулює конвенцію:
«By convention, we use
structfor types where the type's invariant is trivially established. If a type has invariants and requires operations to maintain them, we useclass.»
Ключове поняття тут — інваріант (invariant): умова, яка завжди має бути істинною для коректного об'єкта. Наприклад, у банківського рахунку інваріант може бути «баланс не може стати від'ємним без дозволу» або «ліміт операцій не може перевищити встановлений поріг». Ці умови потрібно захищати: не дозволяти зовнішньому коду порушити їх через довільне присвоєння полів.
Розберемо аргумент по кроках:
Крок 1: struct = plain data, без інваріантів
Коли ви оголошуєте struct Student { std::string name; int age; double gpa; } — жодного інваріанту немає. Будь-яка комбінація значень є технічно допустимою (навіть age = -5 або gpa = 99.9 — це проблема валідації, але не проблема структури). Всі поля публічні, будь-який код може їх читати і змінювати.
Крок 2: Як тільки з'являється метод — з'являється поведінка, а з нею — ймовірні інваріанти
Припустимо, ми додали до struct BankAccount метод withdraw:
struct BankAccount
{
std::string owner;
double balance; // публічне!
bool withdraw(double amount)
{
if (amount > balance) return false;
balance -= amount;
return true;
}
};
Ми намагаємося захистити баланс від від'ємних значень через метод withdraw. Але balance — публічне поле. Будь-який код поза структурою може написати account.balance = -99999.0 — і жодний метод цьому не перешкодить. Інваріант «баланс не може бути від'ємним» не захищений.
Структура з публічними полями і методами, що намагаються підтримувати інваріанти, — це внутрішнє протиріччя: метод каже «захищаю», а відкриті поля кажуть «ні, не захищаєш».
Крок 3: Для захисту інваріантів потрібна інкапсуляція — а це class
class BankAccount // клас — правильний інструмент
{
private: // поля приховані — захист інваріанту
std::string owner_;
double balance_;
public:
BankAccount(const std::string& owner, double initialBalance)
: owner_(owner), balance_(initialBalance) {}
bool withdraw(double amount)
{
if (amount > balance_) return false;
balance_ -= amount;
return true;
}
double getBalance() const { return balance_; }
};
Тепер жоден зовнішній код не може написати account.balance_ = -99999.0 — поле private. Інваріант захищено архітектурно, а не лише покладанням на «добру волю» програміста.
Підсумок позиції Страуструпа
struct— для plain data (POD, aggregate): набір полів без інваріантів, всі поля публічні, операції — через вільні функції.class— для типів із поведінкою і інваріантами: поля приховані черезprivate, доступ лише через публічний інтерфейс методів.
struct із методами, досвідчений C++ програміст запитає: «Чому не class? Які інваріанти тут є і чи захищені вони?».Практика
struct DivisionResult { bool ok; double value; std::string error; };. Напишіть DivisionResult safeDivide(double a, double b) — повертає помилку при b == 0, інакше a / b. Напишіть кілька тест-кейсів: 10/2, 7/0, -15/3.Оголосіть:
struct HttpOptions
{
int timeoutMs = 5000;
bool followRedirects = true;
bool verifySSL = true;
int maxRetries = 3;
std::string userAgent = "MyApp/1.0";
};
Напишіть void simulateRequest(const std::string& url, const HttpOptions& opts), що виводить всі параметри запиту. Викличте її тричі: з дефолтними налаштуваннями, з timeout=1000 і maxRetries=1, з verifySSL=false.
struct LogEntry { std::string level; std::string message; std::string timestamp; };. Напишіть фабричні функції LogEntry makeInfo(const std::string& msg), makeWarning, makeError — кожна підставляє відповідний level і поточний час (спростіть: просто рядок "2024-01-01 00:00:00"). Напишіть void writeLog(const LogEntry& entry) — форматований вивід. Продемонструйте запис трьох різних рівнів логування.Резюме серії
Ця серія з чотирьох статей провела нас шляхом від трьох паралельних масивів до архітектурних патернів:
📦 Стаття 33: Основи
struct= агрегатний тип, що описує сутність- Ініціалізація: присвоєння, агрегатна, default, designated
sizeof, padding, вирівнювання
🔄 Стаття 34: Функції
T— копія,const T&— читання,T&/T*— мутація- NRVO: повернення struct без копіювання
- Фабричні функції і трансформації
🗂️ Стаття 35: Колекції
- Масив структур замість паралельних масивів
- Вкладені struct і
person.address.city Node* next— зв'язаний список
🏛️ Стаття 36: Патерни
- Record, Value Object, Result, Named Parameters
- Семантика значення: копіювання незалежне
struct= дані,class= дані + інваріанти
class). Ви вже знаєте майже все, що для цього потрібно: структури, функції, вказівники, посилання. Клас — це struct із захищеними інваріантами, конструктором і деструктором. Весь фундамент вже закладено.Масиви структур і вкладені структури
Масиви структур як заміна паралельним масивам, пошук і сортування колекцій, вкладені struct, динамічні масиви структур та само-референтна структура як основа зв
Символи та таблиця ASCII
Як тип char насправді зберігає числа, що таке кодування, детальна структура таблиці ASCII, арифметика символів та функції <cctype> — фундамент для розуміння рядків у C++.