JavaRush /Курсы /C++ SELF /Перегрузка и аргументы по умолчанию

Перегрузка и аргументы по умолчанию

C++ SELF
14 уровень , 4 лекция
Открыта

1. Введение

Когда вы начинаете реально писать код (не только решать задачки на один экран), быстро появляется бытовая проблема: функции либо плодятся как кролики, либо становятся «комбайнами» на 8 параметров. Первое неудобно читать, второе неудобно вызывать. Перегрузка и аргументы по умолчанию — это два инструмента, которые помогают сделать API ваших функций более человечным: вы зовёте одно и то же действие одним и тем же именем, а детали компилятор подбирает сам.

Представьте, что вы делаете консольное приложение TaskPad — мини‑список задач. У нас уже есть утилиты печати, меню, работа со строками и std::vector<std::string>. И внезапно хочется печатать «разделитель» то из '-', то из '=', то длиной 20, то длиной 40. Можно сделать четыре функции print_line_dash_20(), print_line_dash_40()… но мы же не пишем древний манускрипт, высеченный в камне.

Перегрузка: один смысл — несколько форм вызова

Перегрузка (overload) — это ситуация, когда в одной области видимости есть несколько функций с одним и тем же именем, но разными параметрами. Разными считается число параметров, их типы или порядок. Смысл перегрузки — дать одному действию одно имя, а детали «формы вызова» доверить компилятору.

Начнём с самого приземлённого примера: печать линии‑разделителя.

#include <iostream>

void print_line(char ch, int count) {
    for (int i = 0; i < count; ++i) std::cout << ch;
    std::cout << '\n';
}

void print_line(int count) {               // перегрузка: другой список параметров
    print_line('-', count);
}

Теперь код читается приятно:

print_line(20);        // печатаем "обычную" линию из '-'
print_line('=', 20);   // печатаем "жирную" линию из '='

Обратите внимание на важную деталь: вторая версия print_line(int) не дублирует цикл — она просто использует первую версию как «базовую». Так вы уменьшаете копипасту и вероятность того, что в одной версии вы случайно забудете '\n' (а потом будете страдать, потому что консоль «слиплась»).

Как компилятор выбирает нужную перегрузку

На практике перегрузка пугает новичков не синтаксисом, а вопросом: «а как компилятор понимает, какую именно функцию я имел в виду?» Ответ: компилятор строит список кандидатов по имени и затем выбирает вариант, который лучше всего подходит по типам аргументов. Если подходящих несколько и они одинаково хороши — будет ошибка неоднозначности.

Удобно держать в голове очень простую модель выбора:

Ситуация при вызове Что обычно происходит
Есть версия с точным совпадением типов Она почти всегда выигрывает
Точного совпадения нет, но можно «преобразовать» тип (например, int double) Компилятор может выбрать такую перегрузку
Подходят две перегрузки «примерно одинаково» Ошибка: вызов неоднозначен
Не подходит ни одна Ошибка: нет подходящей функции

Посмотрим на пример:

#include <iostream>

void show(int x) {
    std::cout << "int: " << x << '\n';
}

void show(double x) {
    std::cout << "double: " << x << '\n';
}

int main() {
    show(10);      // int: 10
    show(3.14);    // double: 3.14
}

Тут всё прозрачно: 10 — это int, 3.14 — это double.

А теперь пример, где можно получить неожиданность. Если вы сделаете перегрузки слишком похожими, компилятор иногда честно скажет: «Я не экстрасенс».

Чтобы не превращать ваш код в детектив, практическое правило такое: перегрузки должны отличаться так, чтобы у читателя (и у компилятора) был очевидный выбор.

Ограничение: нельзя перегрузить только по типу возврата

Обычно в этот момент у кого‑то появляется идея: «А можно я сделаю int parse(...) и bool parse(...), и компилятор сам выберет по тому, куда я присваиваю?» Увы, нет. В C++ перегрузка не может отличаться только типом возврата — должна отличаться параметрами. И это логично: когда вы пишете parse("123"), компилятору надо выбрать функцию прямо здесь и сейчас, не гадая, что вы потом сделаете с результатом.

Вот пример того, что не компилируется:

#include <string>

int parse(std::string s) {
    return 0;
}

bool parse(std::string s) {   // ошибка: отличается только возвращаемый тип
    return true;
}

Поэтому если вам нужно два разных результата — вы либо меняете имя (parse_int, parse_bool), либо меняете параметры (например, добавляете режим парсинга), либо проектируете возвращаемое значение иначе (но это уже более поздние темы).

2. Аргументы по умолчанию: делаем «типичный случай» коротким

Аргументы по умолчанию (default arguments) решают другую проблему: не «две функции», а «одна функция, но чаще всего её вызывают одинаково». Тогда можно один раз указать стандартные значения для части параметров и при вызове эти аргументы не писать.

Вернёмся к линии‑разделителю. Вместо перегрузки мы можем сделать одну функцию:

#include <iostream>

void print_line(char ch = '-', int count = 20) {
    for (int i = 0; i < count; ++i) std::cout << ch;
    std::cout << '\n';
}

int main() {
    print_line();          // печатает 20 символов '-'
    print_line('=');       // печатает 20 символов '='
    print_line('=', 8);    // печатает 8 символов '='
}

Здесь появляется ощущение «удобной кнопки»: обычный случай ('-' и 20) вообще не требует параметров, а нестандартные варианты задаются явно.

Важное правило: значения по умолчанию можно задавать только для хвостовых параметров (то есть справа). С точки зрения здравого смысла это тоже логично: если вы пропустили аргумент в середине, то как компилятор поймёт, какой именно вы пропустили?

Вот пример того, как нельзя:

// так нельзя: после параметра с default идёт параметр без default
void f(int a = 10, int b) { }

Где писать значения по умолчанию

Когда вы уже используете прототипы (объявления функций перед main), возникает практичный вопрос: где указывать = ...? Хорошая привычка — держать «контракт» функции в одном месте. Поэтому значения по умолчанию обычно пишут в объявлении (прототипе), а в определении оставляют «чистую» сигнатуру без повторов.

Пример в стиле «правильно и аккуратно»:

#include <iostream>
#include <string>

void greet(std::string name = "World"); // default в объявлении

int main() {
    greet();          // Hello, World!
    greet("Alice");   // Hello, Alice!
}

void greet(std::string name) {          // без "= ..."
    std::cout << "Hello, " << name << "!\n";
}

Почему так удобнее? Потому что если вы случайно укажете разные значения по умолчанию в разных местах, вы получите либо ошибку, либо (в более сложных случаях) очень странное поведение и много боли. Держать default‑значения в одном месте — это как держать ключи от квартиры в одном кармане: можно и по‑другому, но зачем.

3. Что выбрать и где ловушка

Эти два инструмента часто решают похожие задачи, но ощущаются по‑разному. Перегрузка обычно лучше, когда у вас реально разные сценарии и вы хотите, чтобы сигнатуры выглядели по‑разному и читались по‑разному. Default‑аргументы лучше, когда сценарий один, но есть «настройки», которые в 80% случаев одинаковые.

Сравним на примере печати заголовка меню в TaskPad. Мы хотим печатать рамку и текст. Иногда ширина по умолчанию 30, иногда 50.

Вариант с default:

#include <iostream>
#include <string>

void print_title(std::string title, int width = 30) {
    std::cout << '[' << title << "] width=" << width << '\n';
}

Вариант с перегрузкой:

#include <iostream>
#include <string>

void print_title(std::string title, int width) {
    std::cout << '[' << title << "] width=" << width << '\n';
}

void print_title(std::string title) {
    print_title(title, 30);
}

И тот, и другой вариант нормальный. Но по ощущениям:

Перегрузка делает «обычный вызов» отдельной функцией, и это иногда легче объяснять начинающим: одна функция «простая», другая «расширенная». Default‑аргумент делает одну функцию более гибкой, но требует помнить правила про хвостовые параметры.

Ловушка: перегрузка + default‑аргументы и неоднозначность

Самая частая причина фразы «я ничего не понял, оно вчера работало»: вы совместили перегрузки и default‑аргументы так, что один и тот же вызов подходит к двум вариантам сразу. Компилятор не выберет «как вам хотелось», он выберет «ошибку» (и будет прав).

Классический пример:

#include <iostream>

int area(int side) {
    return side * side;
}

int area(int w, int h = 1) {
    return w * h;
}

int main() {
    // std::cout << area(5) << '\n'; // неоднозначно: подходят обе функции
    std::cout << area(5, 2) << '\n'; // ок
}

Вызов area(5) подходит и к area(int) напрямую, и к area(int, int) потому что второй параметр можно «домолчать» как 1. Компилятор не обязан угадывать, что вы имели в виду.

Практическое правило: если вы уже используете default‑аргументы, старайтесь не делать перегрузку, которая перекрывает те же формы вызова. И наоборот: если вы делаете перегрузки, не добавляйте default‑значения так, чтобы они создавали одинаковые «формы».

4. Пример TaskPad: делаем печать и ввод удобнее

Сейчас мы соберём несколько маленьких функций, которые вы сможете использовать в TaskPad. Мы не будем усложнять архитектуру и не будем залезать в будущие темы передачи по ссылке — пока всё максимально прямолинейно: небольшие функции, понятные параметры, предсказуемые вызовы.

Для начала — печать «шапки» и линии.

#include <iostream>
#include <string>

void print_line(char ch = '-', int count = 30) {
    for (int i = 0; i < count; ++i) std::cout << ch;
    std::cout << '\n';
}

void print_header(std::string title) {
    print_line('=');
    std::cout << title << '\n';
    print_line('=');
}

Теперь main можно сделать похожим на сценарий, а не на «простыню вывода»:

print_header("TaskPad");
print_line();          // обычная линия
print_line('.', 10);   // линия покороче и другим символом

Следующий бытовой кусок: печать меню. Тут удобно показать, что default‑аргументы помогают «не спамить» параметрами. Мы хотим печатать нумерованное меню, а ширину оформления делать по умолчанию 30.

#include <iostream>

void print_menu(int width = 30) {
    print_line('-', width);
    std::cout << "1) Add task\n";
    std::cout << "2) List tasks\n";
    std::cout << "0) Exit\n";
    print_line('-', width);
}

Обратите внимание: это хороший пример default‑аргумента, потому что «ширина меню» — настройка, которая почти всегда одна и та же, но иногда хочется шире.

Теперь добавим штуку, которая в консольных приложениях встречается постоянно: «считать команду». Пока мы не углубляемся в «надёжный ввод при ошибках», но уже можем сделать интерфейс симпатичным: по умолчанию показываем приглашение "> ".

#include <iostream>
#include <string>

std::string read_line(std::string prompt = "> ") {
    std::cout << prompt;
    std::string s;
    std::getline(std::cin, s);
    return s;
}

Использование:

std::string cmd = read_line();                 // печатает "> "
std::string name = read_line("Task name: ");   // печатает "Task name: "

Теперь маленький момент про перегрузку: иногда хочется, чтобы read_line() умел возвращать пустую строку и это нормально, но иногда хочется «ввести не пустое». Мы можем сделать перегрузку, которая принимает флаг, требующий непустой ввод, и в плохом случае повторяет запрос. Мы не делаем бесконечную «супер‑валидацию», просто показываем идею.

#include <iostream>
#include <string>

std::string read_line(std::string prompt) {
    std::cout << prompt;
    std::string s;
    std::getline(std::cin, s);
    return s;
}

std::string read_line(std::string prompt, bool non_empty) {
    while (true) {
        std::string s = read_line(prompt);
        if (!non_empty || !s.empty()) return s;
        std::cout << "Please enter non-empty text.\n";
    }
}

И вызовы выглядят осмысленно:

std::string a = read_line("> ");                  // простая версия
std::string b = read_line("Task name: ", true);   // требуем непустое

Здесь перегрузка уместна, потому что сценарии реально разные: «просто прочитать» и «прочитать, но не пустое». Можно было бы сделать default‑аргумент bool non_empty = false, но тогда у новичков часто возникает когнитивная боль: «а что означает true вторым параметром?» Перегрузка иногда делает код более самодокументируемым, особенно если у вас появляются разные формы вызова.

Наконец, пример того, как можно неудачно совместить перегрузку и default, и почему мы так не делаем в TaskPad. Если вы решите написать так:

std::string read_line(std::string prompt = "> ");
std::string read_line();

то вы очень легко придёте к ситуациям, когда read_line() можно вызвать разными путями, а дальше начинаются ошибки и сюрпризы. Поэтому мы выбираем один стиль для одной «семьи» функций.

5. Мини‑схема выбора

Когда вы сомневаетесь, полезно сделать короткую «ментальную блок‑схему». Не потому что вы обязаны рисовать диаграммы каждый раз (хотя иногда хочется), а потому что это дисциплинирует дизайн.

flowchart TD
    A["Хочу сделать вызов функции удобнее"] --> B{"Это один сценарий с настройками?"}
    B -->|Да| C["Default arguments (значения по умолчанию)"]
    B -->|Нет| D{"Это разные сценарии, и формы вызова разные?"}
    D -->|Да| E["Overload (перегрузки)"]
    D -->|Нет| F["Лучше переименовать функцию или упростить интерфейс"]

И важная приписка (мысленно): «Не смешивать перегрузки и default‑аргументы так, чтобы один и тот же вызов подходил сразу к двум вариантам».

6. Типичные ошибки

Ошибка №1: попытка перегрузить функцию только по возвращаемому типу.
Это выглядит заманчиво, потому что «результат-то разный», но компилятор выбирает функцию по имени и параметрам. Если параметры одинаковые, две функции становятся конфликтом, и код не компилируется. В таких случаях лучше менять имя или менять список параметров так, чтобы выбор был однозначен.

Ошибка №2: аргументы по умолчанию поставлены не справа.
Новички часто хотят «сделать optional серединку», но в C++ значения по умолчанию разрешены только для хвостовых параметров. Если после параметра с = ... идёт параметр без значения по умолчанию, это ломает сигнатуру и приводит к ошибке компиляции. Лечится перестановкой параметров или другим дизайном.

Ошибка №3: сочетание перегрузки и default‑аргумента, которое создаёт неоднозначный вызов.
Самая неприятная ошибка, потому что она выглядит «логично», пока не увидишь сообщение компилятора. Типовой сценарий: есть f(int) и f(int, int = 0), и внезапно f(5) становится неоднозначным. Это не баг компилятора, это ваш интерфейс сказал две разные вещи одинаковой формой. В таких местах выбирайте либо перегрузку без default, либо одну функцию с default, но не оба пути сразу.

Ошибка №4: значения по умолчанию размазаны по коду.
Когда часть default‑значений указана в одном объявлении, часть — в другом, а определение ещё и «тоже что-то знает», вы получаете ситуацию, где контракт функции не читается в одном месте. Для поддержки кода это почти гарантированная ловушка. Гораздо спокойнее держать default‑значения в одном объявлении (обычно в прототипе), а определение делать без повторов.

Ошибка №5: перегрузки слишком похожи, и читатель не понимает, чем они отличаются.
Даже если компилятор способен выбрать нужную перегрузку, человек потом будет читать код глазами и пытаться понять смысл. Если различие между перегрузками не отражает различие сценариев, вы получите не «удобный API», а «ребус». В этом случае лучше либо переименовать функции, либо сделать одну функцию с параметрами, которые явно отражают настройку.

1
Задача
C++ SELF, 14 уровень, 4 лекция
Недоступна
Привет для гостя
Привет для гостя
1
Задача
C++ SELF, 14 уровень, 4 лекция
Недоступна
Периметр упаковки
Периметр упаковки
1
Задача
C++ SELF, 14 уровень, 4 лекция
Недоступна
Печать меню
Печать меню
1
Задача
C++ SELF, 14 уровень, 4 лекция
Недоступна
Рамка сообщения
Рамка сообщения
1
Опрос
Функции, 14 уровень, 4 лекция
Недоступен
Функции
Функции: сигнатуры и return
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ