2.6 – Предварительные объявления и определения
Взгляните на этот, казалось бы, правильный пример программы:
#include <iostream>
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}
int add(int x, int y)
{
return x + y;
}
Вы ожидаете, что эта программа даст результат:
The sum of 3 and 4 is: 7
Но на самом деле она вообще не компилируется! Visual Studio выдает следующую ошибку компиляции:
add.cpp(5) : error C3861: 'add': identifier not found
Причина, по которой эта программа не компилируется, заключается в том, что компилятор последовательно компилирует содержимое исходных файлов. Когда компилятор достигает вызова функции add
в строке 5 в функции main
, он не знает, что такое add
, потому что мы определили add
только в строке 9! Это вызывает ошибку, «identifier not found» (идентификатор не найден).
Более старые версии Visual Studio выдали бы дополнительную ошибку:
add.cpp(9) : error C2365: 'add' : redefinition; previous definition was 'formerly unknown identifier'
Это несколько вводит в заблуждение, учитывая, что add
в первый раз вообще не была определена. Несмотря на это, в целом полезно отметить, что довольно часто одна ошибка вызывает множество повторяющихся или связанных ошибок или предупреждений.
Лучшая практика
При устранении ошибок компиляции в своих программах всегда сначала устраняйте первую возникшую ошибку, а затем снова попробуйте скомпилировать программу.
Чтобы решить эту проблему, нам нужно разобраться с тем фактом, что компилятор не знает, что такое add
. Есть два распространенных способа решения этой проблемы.
Вариант 1. Изменение порядка определений функций
Один из способов решения проблемы – переупорядочить определения функций так, чтобы add
была определена перед main
:
#include <iostream>
int add(int x, int y)
{
return x + y;
}
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}
Таким образом, к моменту вызовов функции add
из main
компилятор уже будет знать, что такое add
. Поскольку это очень простая программа, сделать это изменение относительно легко. Однако в более большой программе может быть утомительно пытаться выяснить, какие функции вызывают какие другие функции (и в каком порядке), чтобы их можно было определять последовательно.
Кроме того, этот вариант не всегда возможен. Допустим, мы пишем программу, которая имеет две функции A
и B
. Если функция A
вызывает функцию B
, а функция B
вызывает функцию A
, то нет способа упорядочить функции таким образом, чтобы компилятор был доволен. Если вы сначала определите A
, компилятор пожалуется, что не знает, что такое B
. Если вы сначала определите B
, компилятор пожалуется, что не знает, что такое A
.
Вариант 2. Использование предварительного объявления
Мы также можем исправить это, используя предварительное объявление.
Предварительное объявление позволяет нам сообщить компилятору о существовании идентификатора до его фактического определения.
В случае функций это позволяет нам сообщить компилятору о существовании функции до того, как мы определим тело функции. Таким образом, когда компилятор встретит вызов функции, он поймет, что мы выполняем вызов функции, и сможет проверить, правильно ли мы вызываем функцию, даже если он еще не знает, как и где эта функция определена.
Чтобы написать предварительное объявление для функции, мы используем инструкцию объявления, называемую прототипом функции. Прототип функции состоит из типа возвращаемого значения функции, имени и параметров, но не содержит тела функции (фигурные скобки и всё, что между ними), и заканчивается точкой с запятой.
Вот прототип функции для функции add
:
// Прототип функции включает в себя тип возвращаемого значения, имя,
// параметры и точку с запятой. Тело функции отсутствует!
int add(int x, int y);
Итак, вот наша исходная программа, которая не компилировалась, использующая прототип функции в качестве предварительного объявления для функции add
:
#include <iostream>
int add(int x, int y); // предварительное объявление add() (с использованием прототипа функции)
int main()
{
// это работает потому, что мы предварительно объявили add() выше
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}
int add(int x, int y) // хотя тело add() не определено до этого момента
{
return x + y;
}
Теперь, когда компилятор дойдет до вызова add
в main
, он будет знать, как выглядит add
(функция, которая принимает два целочисленных параметра и возвращает целое число), и не будет жаловаться.
Стоит отметить, что в прототипах функций не нужно указывать имена параметров. В приведенном выше коде вы также можете предварительно объявить эту функцию следующим образом:
int add(int, int); // корректный прототип функции
Однако мы предпочитаем давать имена параметрам (используя те же имена, что и реальная функция), потому что это позволяет вам понять, какие параметры использует функция, просто взглянув на ее прототип. В противном случае вам нужно будет найти определение функции.
Лучшая практика
При определении прототипов функций сохраняйте имена параметров. Вы можете легко создавать предварительные объявления, используя копирование/вставку из определения функции. Не забудьте в конце поставить точку с запятой.
Забываем о теле функции
Программисты-новички часто задаются вопросом, что произойдет, если они дадут предварительное объявление функции, но не дадут ее определение.
Ответ: это зависит от обстоятельств. Если предварительное объявление сделано, но функция никогда не вызывается, программа будет компилироваться и работать нормально. Однако если предварительное объявление сделано, и функция вызывается, но программа никогда не определяет эту функцию, программа будет компилироваться нормально, но компоновщик (линкер) будет жаловаться, что не может разрешить вызов функции.
Рассмотрим следующую программу:
#include <iostream>
int add(int x, int y); // предварительное объявление add() с использованием прототипа функции
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}
// примечание: нет определения для функции add
В этой программе мы даем предварительное объявление add
и вызываем add
, но нигде не определяем add
. Когда мы пытаемся скомпилировать эту программу, Visual Studio выдает следующее сообщение:
Compiling...
add.cpp
Linking...
add.obj : error LNK2001: unresolved external symbol "int __cdecl add(int,int)" (?add@@YAHHH@Z)
add.exe : fatal error LNK1120: 1 unresolved externals
Как видите, программа скомпилировалась нормально, но проблема возникла на этапе компоновки, потому что int add(int, int)
никогда не определялась.
Другие типы предварительных объявлений
Предварительные объявления чаще всего используются с функциями. Однако предварительные объявления в C++ также могут использоваться и с другими идентификаторами, такими как переменные и пользовательские типы. Переменные и пользовательские типы имеют отличающийся синтаксис для предварительного объявления, поэтому мы рассмотрим их в будущих уроках.
Объявления против определений
В C++ вы часто будете слышать слова «объявление» и «определение», часто взаимозаменяемые. Что они имеют в виду? Теперь у вас достаточно знаний, чтобы понять разницу между ними.
Определение фактически реализует (для функций или типов) или создает экземпляр (для переменных) идентификатора. Вот несколько примеров определений:
int add(int x, int y) // реализует функцию add()
{
int z{ x + y }; // создает экземпляр переменной z
return z;
}
Определение требуется, чтобы удовлетворить компоновщик (линкер). Если вы используете идентификатор без определения, компоновщик выдаст ошибку.
Правило одного определения (или сокращенно ODR, one definition rule) – это хорошо известное правило в C++. ODR состоит из трех частей:
- В заданном файле функция, объект, тип или шаблон могут иметь только одно определение.
- В заданной программе объект или обычная функция может иметь только одно определение. Это выделено потому, что программы могут иметь более одного файла (мы рассмотрим это в следующем уроке).
- Типы, шаблоны, встроенные функции и переменные могут иметь идентичные определения в разных файлах. Мы еще не рассмотрели большинство из этих вещей, поэтому не беспокойтесь об этом сейчас – мы вернемся к ним, когда это будет уместно.
Нарушение пункта 1 правила одного определения приведет к тому, что компилятор выдаст ошибку переопределения. Нарушение пункта 2 правила одного определения может привести к тому, что компоновщик выдаст ошибку переопределения. Нарушение пункта 3 правила одного определения приведет к неопределенному поведению.
Вот пример нарушения пункта 1:
int add(int x, int y)
{
return x + y;
}
int add(int x, int y) // нарушение ODR, мы уже определили функцию add
{
return x + y;
}
int main()
{
int x;
int x; // нарушение ODR, мы уже определили x
}
Поскольку указанная выше программа нарушает пункт 1 правила одного определения, компилятор Visual Studio выдает следующие ошибки компиляции:
project3.cpp(9): error C2084: function 'int add(int,int)' already has a body
project3.cpp(3): note: see previous definition of 'add'
project3.cpp(16): error C2086: 'int x': redefinition
project3.cpp(15): note: see declaration of 'x'
Для продвинутых читателей
Функции, которые имеют общий идентификатор, но имеют разные параметры, считаются отдельными функциями. Мы обсудим это далее в уроке «8.9 – Перегрузка функций».
Объявление – это инструкция, которая сообщает компилятору о существовании идентификатора и информацию о его типе. Вот несколько примеров объявлений:
// сообщает компилятору о функции с именем "add", которая принимает два параметра типа int
// и возвращает значение типа int. Тела нет!
int add(int x, int y);
// сообщает компилятору о целочисленной переменной с именем x
int x;
Объявление – это всё, что нужно компилятору. Вот почему мы можем использовать предварительное объявление, чтобы сообщить компилятору об идентификаторе, который на самом деле не будет определен позже.
В C++ все определения также служат объявлениями. Вот почему int x
появляется в наших примерах как для определений, так и для объявлений. Поскольку int x
– это определение, оно же и объявление. В большинстве случаев определение служит нашим целям, поскольку оно удовлетворяет и компилятор, и компоновщик. Явное объявление нам нужно предоставить только тогда, когда мы хотим использовать идентификатор до того, как он будет определен.
Хотя верно, что все определения являются объявлениями, обратное неверно: все объявления не являются определениями. Примером этого является прототип функции – он удовлетворяет компилятор, но не компоновщик. Объявления, которые не являются определениями, называются чистыми объявлениями. Другие типы чистых объявлений включают в себя предварительные объявления для переменных и объявления типов (вы столкнетесь с ними в будущих уроках, сейчас о них не нужно беспокоиться).
ODR не применяется к чистым объявлениям (это правило одного определения, а не правило одного объявления), поэтому вы можете иметь столько чистых объявлений для идентификатора, сколько хотите (хотя наличие более одного является избыточным).
Примечание автора
В обычном языке термин «объявление» обычно используется для обозначения «чистого объявления», а «определение» используется для обозначения «определения, которое также служит объявлением». Таким образом, мы обычно называем int x;
определением, хотя это и определение, и объявление.
Небольшой тест
Вопрос 1
Что такое прототип функции?
Ответ
Прототип функции – это инструкция объявления, которая включает в себя имя функции, тип возвращаемого значения и параметры. Он не включает в себя тело функции.
Вопрос 2
Что такое предварительное объявление?
Ответ
Предварительное объявление сообщает компилятору, что идентификатор существует до того, как он будет фактически определен.
Вопрос 3
Как мы даем предварительное объявление для функций?
Ответ
Для функций предварительным объявлением служит прототип функции.
Другие типы идентификаторов (например, переменные и пользовательские типы) имеют для предварительного объявления отличающийся синтаксис.
Вопрос 4
Напишите прототип для этой функции (используйте предпочтительную форму с именами):
int doMath(int first, int second, int third, int fourth)
{
return first + second * third / fourth;
}
Ответ
// Не забывайте точку с запятой в конце, так как это инструкция.
int doMath(int first, int second, int third, int fourth);
Вопрос 5
Для каждой из следующих программ укажите, завершится ли их компиляция неудачей, завершится ли их линковка неудачей, или будут успешными и компиляция, и линковка. Если не уверены, попробуйте их скомпилировать!
a)
#include <iostream>
int add(int x, int y);
int main()
{
std::cout << "3 + 4 + 5 = " << add(3, 4, 5) << '\n';
return 0;
}
int add(int x, int y)
{
return x + y;
}
Ответ
Не компилируется. Компилятор будет жаловаться, что add()
, вызываемая в main()
, не имеет того же количества параметров, что и та, что была предварительно объявлена.
b)
#include <iostream>
int add(int x, int y);
int main()
{
std::cout << "3 + 4 + 5 = " << add(3, 4, 5) << '\n';
return 0;
}
int add(int x, int y, int z)
{
return x + y + z;
}
Ответ
Не компилируется. Компилятор будет жаловаться, что не может найти подходящую функцию add()
, которая принимает 3 аргумента, потому что функция add()
, которая была предварительно объявлена, принимает только 2 аргумента.
c)
#include <iostream>
int add(int x, int y);
int main()
{
std::cout << "3 + 4 + 5 = " << add(3, 4) << '\n';
return 0;
}
int add(int x, int y, int z)
{
return x + y + z;
}
Ответ
Не линкуется. Компилятор сопоставит предварительно объявленный прототип add
с вызовом функции add()
в main()
. Однако функция add()
, которая принимает два параметра, никогда не была реализована (мы реализовали только ту, которая принимает 3 параметра), поэтому компоновщик будет жаловаться.
d)
#include <iostream>
int add(int x, int y, int z);
int main()
{
std::cout << "3 + 4 + 5 = " << add(3, 4, 5) << '\n';
return 0;
}
int add(int x, int y, int z)
{
return x + y + z;
}
Ответ
Компилируется и линкуется. Вызов функции add()
соответствует прототипу, который был предварительно объявлен, реализованная функция также совпадает.