JavaRush /Курсы /C++ SELF /Прототипы и порядок кода

Прототипы и порядок кода

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

1. Почему компилятор «не видит» функцию

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

Представьте такой код:

#include <iostream>

int main() {
    print_menu();            // ошибка: функция ещё "не известна"
    std::cout << "Done\n";   // Done
}

Если вы попробуете это собрать, компилятор скажет примерно что-то вроде: print_menu was not declared in this scope (или аналогичное сообщение в вашей среде). Смысл один: в месте вызова функции компилятор ещё не видел её объявления.

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

Компилятор читает файл сверху вниз

Звучит банально, но на практике это половина всех новичковых ошибок, связанных с функциями. Компилятор обрабатывает один .cpp-файл как текст: от первой строки к последней. Когда он встречает вызов print_menu(), ему нужно уже понимать, что это за сущность: функция ли это, какие у неё параметры, что она возвращает.

Можно представить это как чтение книги. Если в первой главе появляется персонаж «Гэндальф», а автор только в пятой главе объясняет, кто это вообще такой — читателю будет странно. Компилятор, в отличие от читателя, не любит «потом объясню». Он любит, чтобы «паспорт героя» показали заранее.

Небольшая блок-схема, как это ощущается:

flowchart TD
    A[Компилятор читает файл сверху вниз] --> B{"Встретил вызов name(...)?"}
    B -->|Да| C{Знает сигнатуру name?}
    C -->|Да| D[Проверяет аргументы и компилирует вызов]
    C -->|Нет| E[Ошибка: функция не объявлена]
    B -->|Нет| A

Отсюда рождается правило сегодняшней лекции:

Функция должна быть объявлена до места, где вы её вызываете (в пределах файла).

2. Объявление, определение и прототип

«Объявление» и «определение» — одно имя, две роли

Чтобы вылечить проблему «не видно функцию», нужно различать два очень похожих термина: объявление и определение. Обычно новичкам кажется, что это одно и то же, но на деле они играют разные роли.

Нормальная жизненная аналогия: объявление — это как табличка на двери «Кабинет 305, терапевт». Определение — это сам врач в кабинете, который реально лечит. Если таблички нет, вы можете даже не догадаться, что здесь можно лечиться, пока не зайдёте случайно.

Табличкой для функции является прототип (то есть объявление функции без тела):

int sum(int a, int b); // объявление (прототип)

А «врач в кабинете» — это определение (объявление + тело):

int sum(int a, int b) { // определение
    return a + b;
}

Соберём в компактную таблицу:

Что это Как выглядит Зачем нужно
Объявление (прототип)
int sum(int a, int b);
Сообщить компилятору: «такая функция существует, вот её сигнатура»
Определение (реализация)
int sum(int a, int b) { ... }
Дать реальный код, который будет выполнен

Небольшая терминологическая ремарка: в стандартах и технических текстах чаще говорят «declaration» (объявление), а слово «prototype» воспринимается как разговорное упрощение.

Прототип функции: «покажи паспорт заранее»

Когда вы пишете прототип, вы буквально даёте компилятору «паспорт» функции: имя, тип результата и список параметров. После этого main может стоять выше, а определения — ниже, и всё будет работать.

Исправим пример с print_menu() правильно: добавим прототип выше main.

#include <iostream>

void print_menu(); // прототип

int main() {
    print_menu();
    std::cout << "Done\n";   // Done
}

Теперь компилятор уже «в курсе», что print_menu — функция, которая ничего не возвращает (void) и не принимает аргументов (()).

А где же тело? Ниже:

#include <iostream>

void print_menu(); // прототип

int main() {
    print_menu();
}

void print_menu() {            // определение
    std::cout << "1) Start\n"; // 1) Start
}

Обратите внимание на одну мелочь, которая очень любит ломать компиляцию: прототип заканчивается точкой с запятой. Прямо как объявление переменной. Если вы забудете ;, компилятор решит, что вы начали определение, и дальше начнётся каскад странных ошибок.

В прототипе можно не писать имена параметров

Да, можно. Компилятору важны типы и порядок параметров, а не имена. Имена нужны людям (то есть вам будущему, через неделю) — для читаемости.

Вот так можно:

int sum(int, int); // имена параметров опущены

А вот так читаемее:

int sum(int a, int b); // имена помогают понять смысл

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

Прототип и определение должны совпадать

Есть очень коварная ситуация: вы добавили прототип, программа стала «видеть функцию», но потом вы поменяли определение и забыли поменять прототип. Или наоборот. В результате компилятор может ругаться очень неожиданно, потому что он проверяет вызовы по прототипу, а потом видит определение, которое «не то».

Например, вот прототип говорит, что функция принимает int:

int square(int x); // прототип

А определение внезапно принимает double:

int square(double x) { // не совпадает с прототипом!
    return static_cast<int>(x * x);
}

Это разные функции (по сути), и компилятор будет недоволен. В лучшем случае вы получите ошибку компиляции, в худшем — вы начнёте «чинить не ту строку», потому что сообщение об ошибке будет про несоответствие объявлений.

Правило тут простое и полезное: прототип — это контракт, определение — реализация контракта. Контракт нельзя менять “тихо” в одном месте.

3. Как организовать код в файле

Можно ли просто переставить функции местами

Можно — и иногда это даже проще. Если функция маленькая и логично ставится выше main, можно просто написать её реализацию до main, и никакие прототипы не нужны.

Так тоже правильно:

#include <iostream>

void print_menu() {
    std::cout << "1) Start\n"; // 1) Start
}

int main() {
    print_menu();
}

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

Это не «единственно правильный стиль», но это хороший компромисс для читаемости.

Мини-структура файла для учебных проектов

Сейчас мы всё ещё работаем в одном файле, без .hpp/.cpp и без разбиения на модули. Но уже хочется порядка. Хорошая мини-структура для учебного проекта выглядит так: наверху подключения библиотек, затем прототипы, затем main, затем определения функций.

Скелет выглядит примерно так:

#include <iostream>
#include <string>

void print_title();
int read_int(std::string prompt);

int main() {
    // сценарий программы
}

void print_title() {
    // детали реализации
}

int read_int(std::string prompt) {
    // детали реализации
    return 0;
}

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

4. Практический пример: мини-приложение NumberBuddy

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

Начнём с прототипов

Сначала опишем, какие функции у нас вообще есть. Это как оглавление: по нему видно, что умеет программа, даже не читая реализацию.

#include <iostream>
#include <string>

void print_title();
int read_int(std::string prompt);
int sum_digits(int n);

Здесь уже видно идею: одна функция печатает заголовок, другая читает число, третья считает сумму цифр.

main как сценарий

main будет коротким и «разговорным»: заголовок → ввод → вычисление → вывод. Заметьте: детали ввода и вычисления мы прячем, а main читается почти как инструкция.

int main() {
    print_title();

    int n = read_int("Enter a number: ");
    int s = sum_digits(n);

    std::cout << "Sum of digits = " << s << '\n'; // например: Sum of digits = 15
}

Даже если вы забыли, как именно считать сумму цифр, main всё равно понятен.

Реализация print_title()

Дальше, ниже по файлу, размещаем реализацию функций. Начнём с самой простой — печать заголовка.

void print_title() {
    std::cout << "=== NumberBuddy ===\n"; // === NumberBuddy ===
}

Реализация read_int(...)

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

int read_int(std::string prompt) {
    std::cout << prompt; // Enter a number:
    int x = 0;
    std::cin >> x;
    return x;
}

Реализация sum_digits(n)

Функция суммирования цифр — классика. Считаем последнюю цифру через % 10, убираем её через / 10 и повторяем, пока число не станет 0.

int sum_digits(int n) {
    if (n < 0) n = -n; // чуть доброты к отрицательным

    int sum = 0;
    while (n > 0) {
        sum += (n % 10);
        n /= 10;
    }
    return sum;
}

Без прототипов этот же код развалится

Если вы оставите main сверху, а определения функций ниже, но уберёте прототипы, компилятор дойдёт до print_title() и скажет: «не знаю, что это». И он будет прав: до определения он ещё не дошёл.

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

5. Типичные ошибки при работе с прототипами и порядком кода

Ошибка №1: забыли точку с запятой после прототипа.
Это одна из самых обидных ошибок, потому что выглядит мелко, а ломает много. Прототип — это объявление, а объявления в C++ обычно заканчиваются ;. Если ; нет, компилятор думает, что дальше должно быть тело {...}, и начинает «догадываться» о вашем коде, часто очень творчески.

Ошибка №2: прототип и определение не совпадают.
Иногда вы меняете тип параметра или добавляете ещё один параметр в определении, а прототип не обновляете. Тогда main проверяется по старому контракту, а ниже в файле находится другая функция. Итог — ошибки несоответствия объявлений или загадочные сообщения о том, что «определение не соответствует предыдущему объявлению».

Ошибка №3: перепутали объявление функции и объявление переменной.
Новички иногда пишут что-то вроде int sum;, думая, что «объявили функцию sum». Но функция всегда имеет круглые скобки (...). Даже если параметров нет, всё равно нужно (): void hello();. Без скобок это будет переменная, и дальше при вызове hello() вы получите очень странные ошибки.

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

Ошибка №5: попытка «спрятать всё в прототипах» и потерять читаемость.
Иногда хочется написать прототипы без имён параметров, чтобы «короче». Компилятору всё равно, а вот вам — нет. В учебном коде имена в прототипах сильно помогают: int read_int(std::string prompt) читается как документация. Если написать read_int(std::string), через пару дней вы будете вспоминать, что там за строка и зачем.

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