2.6 – Предварительные объявления и определения

Добавлено 10 апреля 2021 в 17:09

Взгляните на этот, казалось бы, правильный пример программы:

#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 правила одного определения приведет к тому, что компилятор выдаст ошибку переопределения. Нарушение пункта 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'

Для продвинутых читателей


Функции, которые имеют общий идентификатор, но имеют разные параметры, считаются отдельными функциями. Мы обсудим это далее в уроке «10.7 – Перегрузка функций».

Объявление – это инструкция, которая сообщает компилятору о существовании идентификатора и информацию о его типе. Вот несколько примеров объявлений:

// сообщает компилятору о функции с именем "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() соответствует прототипу, который был предварительно объявлен, реализованная функция также совпадает.

Теги

C++ / CppДля начинающихОбучениеПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.