C++

Патерни struct та межі застосування

Архітектурні патерни використання struct: Record, Value Object, Result type, Named Parameters. Семантика значення, struct vs class і позиція Страуструпа щодо методів у struct.

Патерни struct та межі застосування

Від синтаксису до архітектури

Попередні три статті серії дали нам повний технічний інструментарій: ми вміємо оголошувати структури, ініціалізувати їх, передавати у функції, зберігати в масивах, вкладати одна в одну і будувати динамічні структури даних через само-референтні struct. Але синтаксис — лише засіб. Для того щоб писати якісний код, потрібно розуміти, як і коли застосовувати структури в архітектурному контексті.

Ця стаття присвячена чотирьом практичним патернам використання struct, а в кінці ми чесно розберемо питання, яке часто виникає: «Якщо структура може мати методи — чому б цим не скористатися?». Відповідь, яку дає сам Бйорн Страуструп, — не технічна, а семантична.


Патерн 1: Record (Запис)

Суть патерну

Record — найпростіший і найприродніший спосіб використання struct. Структура є чистим контейнером даних: набором пов'язаних полів, що разом описують одну сутність. Ніякої прихованої логіки, ніякого стану, що потрібно захищати. Всі поля публічні, значення встановлюються ззовні.

Саме так ми використовували Student і Point3D у попередніх статтях. Це — класичний Record.

Record.cpp
#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
[100042] Ноутбук Dell XPS 13 — 42999 грн (5 шт.)
Загальна вартість: 214995 грн
Є в наявності: Так
[200017] Миша Logitech MX3 — 2499 грн (0 шт.)
Є в наявності: Ні
Execution finished with exit code 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 не має ідентичності — два об'єкти з однаковими полями вважаються рівними. Він завжди копіюється, ніколи не розділяє стан між копіями.

ValueObject.cpp
#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;
}
./ValueObject
$ ./ValueObject
Red: rgb(255, 0, 0)
Blue: rgb(0, 0, 255)
Blended: rgb(127, 0, 127)
Blended після copy.r=0: rgb(127, 0, 127) ← незмінний!
Зарплата: 50000.00 UAH
Бонус: 7500.00 UAH
Разом: 57500.00 UAH
Execution finished with exit code 0.

Зверніть рядок 60: після Color copy = blended; copy.r = 0blended.r залишається 127. Семантика значення гарантує повну незалежність копій.

Про Money і дійсні числа: у фінансових застосунках ніколи не зберігають суми у double або float — представлення чисел з рухомою точкою неточне. 0.1 + 0.2 ≠ 0.3 у double. Правильний підхід — зберігати суму у найменших одиницях (центах, копійках) як long long.

Патерн 3: Result (Тип результату з помилкою)

Проблема: функція не може завжди успішно виконатись

Багато функцій можуть завершитись двома способами: успіхом або невдачею. Класичний підхід C — повертати спеціальне значення (-1, nullptr, 0) для позначення помилки. Але це неочевидно: яке значення — помилкове? І що саме пішло не так?

Патерн Result вирішує це елегантно: функція повертає структуру, що містить і результат, і ознаку успіху, і опис помилки.

Result.cpp
#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
$ ./Result
"42" → 42
"-17" → -17
"" → Помилка: Порожній рядок
"12a3" → Помилка: Некоректний символ: 'a'
"+" → Помилка: Лише знак без цифр
Execution finished with exit code 0.

Патерн Result змушує викликача явно перевірити r.ok перед використанням r.value. Це принципово відрізняється від «магічного» return -1 — читач коду одразу бачить, що функція може провалитися, і що саме пішло не так.

У сучасному C++ (C++23) стандартна бібліотека додала 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: обернути параметри у структуру з промовистими іменами полів:

NamedParams.cpp
#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;
}
./NamedParams
$ ./NamedParams
Сортування: за зростанням, без урахування регістру, нестабільне
Сортування: за спаданням, з урахуванням регістру, нестабільне
Пошук "аліса": нечутливо до регістру, підрядок, макс. 5 результатів
Execution finished with exit code 0.

Завдяки designated initializers (C++20) і default member initializers код виклику стає самодокументованим: { .ascending = false } — одразу зрозуміло, що саме відрізняється від дефолтної поведінки. Ті поля, що не вказані, отримують значення за замовчуванням.


struct vs class: єдина технічна різниця

Перед фінальним розбором щодо методів — важливо розвіяти поширену оману: C++ не розрізняє struct і class на рівні можливостей мови. Єдина технічна різниця між ними — доступ за замовчуванням:

structclass
Доступ до членів за замовчуваннямpublicprivate
Успадкування за замовчуваннямpublicprivate
Може мати методи
Може мати конструктор
Може успадковувати
Може бути шаблоном

Тобто технічно struct і class — повні синоніми, різняться лише дефолтним рівнем доступу. Будь-яку class можна записати як struct і навпаки — лише дописавши або видаливши public: / private:.


Методи у struct: технічно можливо

Оскільки struct і class технічно майже еквівалентні, у структуру можна додавати методи:

StructWithMethods.cpp
#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;
}
./StructWithMethods
$ ./StructWithMethods
(0, 0) → (3, 4) : відстань = 5
Execution finished with exit code 0.

Код компілюється. Код працює. І все ж це — порушення семантичної конвенції. Саме тому ця демонстрація розміщена в кінці статті, а не на початку.


Позиція Страуструпа: чому методи у struct — семантична помилка

Бйорн Страуструп, автор мови C++, у своїй книзі «The C++ Programming Language» (4-те видання) чітко артикулює конвенцію:

«By convention, we use struct for types where the type's invariant is trivially established. If a type has invariants and requires operations to maintain them, we use class.»

Ключове поняття тут — інваріант (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. Інваріант захищено архітектурно, а не лише покладанням на «добру волю» програміста.

Підсумок позиції Страуструпа

Конвенція C++:
  • struct — для plain data (POD, aggregate): набір полів без інваріантів, всі поля публічні, операції — через вільні функції.
  • class — для типів із поведінкою і інваріантами: поля приховані через private, доступ лише через публічний інтерфейс методів.
Порушення цієї конвенції не призведе до помилки компіляції — але воно вводить читача в оману щодо намірів автора і архітектури коду. Побачивши struct із методами, досвідчений C++ програміст запитає: «Чому не class? Які інваріанти тут є і чи захищені вони?».

Практика


Резюме серії

Ця серія з чотирьох статей провела нас шляхом від трьох паралельних масивів до архітектурних патернів:

📦 Стаття 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 із захищеними інваріантами, конструктором і деструктором. Весь фундамент вже закладено.
Copyright © 2026