1. Неявні перетворення через конструктор
Коли ви лише починаєте писати класи, дуже хочеться, щоб усе було зручно: передали int — отримали обʼєкт, передали рядок — теж отримали обʼєкт. Компілятор C++ справді може так зробити: якщо клас має конструктор, який можна викликати одним аргументом, він уміє неявно створювати тимчасовий обʼєкт, коли це «пасує за типами». Спершу це здається магією, потім — фокусом, а згодом — джерелом дивних помилок, які важко пояснити навіть собі о 2:30 ночі.
Погляньмо на проблему на простому прикладі. Уявімо, що в нашому навчальному консольному застосунку «TaskTracker» ми вирішили створити окремий тип для ідентифікатора задачі, щоб не плутати його з іншими числами.
#include <iostream>
class TaskId {
int value_;
public:
TaskId(int v) : value_(v) {}
int value() const { return value_; }
};
void print_task(TaskId id) {
std::cout << "Task id = " << id.value() << '\n'; // Task id = ...
}
int main() {
print_task(42); // Упс: компілятор сам створить TaskId{42}
}
Код скомпілюється. Але головне питання таке: чи справді ми хотіли, щоб int перетворювався на TaskId «сам»? Іноді так. Але набагато частіше — ні, адже «42» у коді без контексту нічого вам не каже: це ідентифікатор задачі, пріоритет, кількість хвилин, версія формату чи знижка у відсотках? (Гаразд, останнє вже не про наш курс.)
Неявні перетворення корисні, коли типи справді взаємозамінні за змістом. Але якщо тип — це змістова обгортка, як-от ідентифікатор, одиниця вимірювання чи маркер стану, то неявність частіше шкодить, ніж допомагає.
Що робить explicit — і чого він не робить
Слово explicit у конструкторі — це спосіб сказати компілятору: «Будь ласка, не добудовуй мені обʼєкт мовчки. Якщо програміст хоче створити цей тип, нехай зробить це явно». Ідеться про читабельність, захист API і про те, щоб змістові помилки ловив компілятор, а не ви вже під час реальної роботи програми.
Важливо розуміти межу: explicit не забороняє створювати обʼєкт узагалі. Він лише забороняє неявне створення.
Виправімо наш TaskId:
#include <iostream>
class TaskId {
int value_;
public:
explicit TaskId(int v) : value_(v) {}
int value() const { return value_; }
};
void print_task(TaskId id) {
std::cout << "Task id = " << id.value() << '\n'; // Task id = 42
}
int main() {
print_task(TaskId{42}); // ок: явно показали сенс
// print_task(42); // помилка компіляції: неявне перетворення заборонено
}
З погляду людини, яка читає код, він став кращим: у місці виклику видно, що ми передаємо саме ідентифікатор задачі, а не якесь випадкове число. Так, доведеться писати трохи більше. Але це той рідкісний випадок, коли кілька зайвих символів заощаджують вам години налагодження і рятують від запитання: «А чому воно взагалі так викликалося?!»
Ще одна важлива ремарка: у стандарті C++ є доволі тонкі правила щодо того, де саме враховується explicit, а відмінності між прямою і копіювальною ініціалізацією — це не фантазія викладачів, а частина формальної моделі мови. Навіть у списках дефектів і уточнень стандарту окремо обговорювали питання конструкторів та explicit у direct-initialization.
Три форми ініціалізації і explicit
Коли ви бачите explicit, майже одразу виникає інше питання: «Добре, неявно не можна. А явно — як?» І тут ми натрапляємо на форми ініціалізації. У C++ той самий зміст — «створити обʼєкт» — можна записати по-різному, і explicit впливає на це неоднаково. Тому корисно один раз спокійно розкласти все по поличках, щоб потім не ворожити за повідомленнями компілятора, наче за кавовою гущею.
Візьмімо наш TaskId з explicit і подивімося на форми:
class TaskId {
int value_;
public:
explicit TaskId(int v) : value_(v) {}
};
Тепер порівняймо різні варіанти:
| Запис | Назва (спрощено) | Працює з explicit? | Ідея |
|---|---|---|---|
|
copy-initialization (копіювальна ініціалізація) | ні | це ніби: «присвой, якщо вдасться перетворити» |
|
direct-initialization (пряма) | так | «виклич конструктор напряму» |
|
list-initialization (спискова) | так | «виклич конструктор через {}» |
| f(5) де f(TaskId) | неявне перетворення аргументу | ні | компілятор намагався б мовчки «добудувати» обʼєкт |
Практичне правило для нашого рівня таке: якщо конструктор explicit, то використовуйте Type{...} або Type(...). І не використовуйте Type x = ... для таких типів-обгорток. У «сильних типах» (TaskId, Minutes, Percent) запис через {} часто виглядає найзрозуміліше й найбезпечніше.
До речі, стандартна бібліотека C++ час від часу «підкручує» explicit там, де зʼясовується, що неявність призводить до надто великих сюрпризів. Наприклад, свого часу обговорювали, що деякі конструктори string_view для діапазонів варто робити explicit, щоб випадкові перетворення не виникали там, де програміст на них не розраховує.
Параметри за замовчуванням: прихований «один аргумент»
Коли ви вже навчилися перевантажувати конструктори, зʼявляється спокуса зробити «універсальний» конструктор із параметрами за замовчуванням: щоб його можна було викликати і з одним аргументом, і з двома. Здається, це логічно. Але тут є пастка: якщо конструктор можна викликати одним аргументом, він знову стає кандидатом на неявні перетворення.
Розгляньмо приклад діапазону:
class Range {
int from_;
int to_;
public:
Range(int from, int to = 0) : from_(from), to_(to) {}
};
Тепер формально Range можна створити як Range{10}, і компілятор може вирішити, що з int «можна зробити Range». Це легко пропустити, особливо якщо Range — не просто «два числа», а обʼєкт з окремим змістом.
Рішення просте: якщо ви допускаєте виклик одним аргументом, але не хочете неявностей, ставте explicit.
class Range {
int from_;
int to_;
public:
explicit Range(int from, int to = 0) : from_(from), to_(to) {}
};
void process(Range r);
int main() {
process(Range{10}); // ок
// process(10); // помилка: неявне перетворення заборонено
}
Ідея тут та сама: нехай місце виклику буде чесним. Якщо ви справді передаєте діапазон, це має бути прямо видно з коду.
2. explicit у «сильних типах» TaskTracker
Тепер перейдемо від абстрактних прикладів до нашої предметної області. Нагадаю контекст: ми поступово розробляємо консольний застосунок TaskTracker. У ньому є задачі, ідентифікатор, заголовок, можливо, пріоритет і нагадування. І ми вже звикаємо виражати зміст типами, а не лише коментарями.
TaskId: щоб не сплутати ідентифікатор із будь-чим
Якщо у нас є функція, яка шукає задачу за ідентифікатором, дуже легко випадково передати в неї щось не те. Наприклад, користувач увів номер пункту меню, а ви помилково передали його як ідентифікатор задачі. Із int компілятор не допоможе: і те int, і це int.
Зробімо TaskId явним:
#include <string>
class TaskId {
int value_;
public:
explicit TaskId(int v) : value_(v) {}
int value() const { return value_; }
};
class Task {
TaskId id_;
std::string title_;
public:
Task(TaskId id, std::string title) : id_(id), title_(std::move(title)) {}
};
Тепер створити Task можна лише тоді, коли ви явно скажете: «це ідентифікатор».
Task t{TaskId{1}, "Read C++ book"};
Так, це трохи довше. Зате якщо ви десь випадково спробуєте написати Task{1, "..."}, компілятор зупинить вас раніше, ніж ви встигнете зіпсувати дані.
Одиниці вимірювання: Minutes як захист від «магічних чисел»
З одиницями вимірювання ситуація ще підступніша. Число 10 може означати «10 хвилин», «10 секунд», «10 днів до дедлайну» або «10 задач у списку». Ми не будемо зараз будувати складну систему часу — це окрема й справді велика тема. Але на рівні поточного заняття можемо зробити дуже просту обгортку.
class Minutes {
int value_;
public:
explicit Minutes(int v) : value_(v) {}
int value() const { return value_; }
};
void set_reminder(Minutes m);
int main() {
set_reminder(Minutes{15}); // ок: явно 15 хвилин
// set_reminder(15); // помилка: не можна мовчки прийняти int
}
Психологічний ефект від такого коду чудовий: ви починаєте писати так, щоб зміст був прямо в рядку коду, а не лише у вашій голові.
3. Коли ставити explicit: практична евристика
Після кількох прикладів зазвичай виникає природне бажання: «Гаразд, тоді давайте зробимо explicit усюди — і буде безпечно!» Бажання зрозуміле. Але якщо переборщити, код стане незручним: кожне створення обʼєкта перетвориться на ритуал, і ви почнете ненавидіти власний API. А це вже серйозна архітектурна помилка.
Ставити explicit майже завжди варто, коли ваш тип — це змістова обгортка навколо іншого типу: TaskId, UserId, Minutes, Percent, PortNumber, FileDescriptor. У таких типах головне не те, як зберігається значення, а те, що воно означає. Неявні перетворення якраз цей зміст і стирають.
Можна розглянути варіант не ставити explicit, якщо ваш тип задуманий як «зручне представлення», яке справді хочеться отримувати автоматично. Класичний приклад зі стандартної бібліотеки — std::string, який можна неявно побудувати з рядкового літерала. Це підвищує зручність повсякденного коду: писати std::string s = "hi"; справді зручно. Але стандартна бібліотека дуже обережна: щойно неявність починає призводити до плутанини, одразу постає питання про переведення конструктора в explicit.
Хороша студентська евристика така: якщо ви створюєте тип, щоб заборонити плутанину, а не щоб додати функціональність, то explicit зазвичай обовʼязковий. Якщо ж ви створюєте тип, щоб спростити роботу з даними, і він має легко прийматися з інших представлень, тоді explicit — питання смаку й контексту, але ставити його «за замовчуванням» уже не обовʼязково.
4. Типові помилки під час використання explicit
Помилка № 1: думати, що explicit «ламає конструктор».
Іноді студенти бачать, що після додавання explicit «усе перестало компілюватися», і роблять висновок, ніби explicit — це щось шкідливе. Насправді він лише відсікає неявні місця, де компілятор раніше сам добудовував створення обʼєкта. Якщо замінити f(5) на f(Type{5}), усе знову запрацює — і код стане зрозумілішим.
Помилка № 2: не враховувати, що параметри за замовчуванням перетворюють конструктор на «одноаргументний».
Ви можете написати конструктор із двома параметрами й щиро вважати, що він «не перетворювальний». Але якщо другий параметр має значення за замовчуванням, то фактично конструктор можна викликати одним аргументом, а отже, знову відчиняються двері для неявних перетворень. У таких випадках explicit часто потрібен так само сильно, як і для справжнього одноаргументного конструктора.
Помилка № 3: і далі використовувати Type x = value; для сильних типів-обгорток.
Після того як ви почали вводити TaskId, Minutes та подібні типи, запис через = стає джерелом зайвих помилок компіляції, бо це copy-initialization. Для таких типів простіше прийняти правило: «обгортки створюємо через {}». Тоді і explicit працює передбачувано, і код однаково виглядає в усьому проєкті.
Помилка № 4: ставити explicit усюди без чіткого критерію й перетворювати API на квест.
Якщо зробити explicit навіть там, де тип справді має створюватися «зручно», ви змусите користувачів вашого коду постійно писати зайві обгортки й фабрики. Це знижує читабельність не менше, ніж неявні перетворення. explicit — це інструмент точкового захисту змісту, а не універсальна «пломба на все».
Помилка № 5: не пояснювати зміст типу назвою і сподіватися, що explicit «сам усе вирішить».
explicit запобігає неявним перетворенням, але не робить тип самопояснювальним. Якщо ви назвали тип X, то X{10} усе одно нічого не каже читачеві. Гарна назва (TaskId, Minutes) + explicit працюють як команда: назва пояснює зміст, а explicit змушує цей зміст явно зʼявлятися в коді.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ