Здравствуйте.
Благодаря вот этому голосованию выяснилось, что на Хабре не хватает статей по такому мощному, но всё менее используемому языку C++. Профессионалам высокого уровня, гуру, магам и волшебникам языка C++, а также тем, кто уже успел оставить этот язык «позади» можно дальше не читать. Сегодня я хочу начать цикл статей, призванных помочь именно новичкам, относительно недавно начавшим изучать этот язык, либо же тем, кто (упаси Боже) читает мало книг, а пытается познавать всё исключительно на практике.
Также я надеюсь привлечь как можно больше авторов к написанию подобных статей, потому как моего опыта здесь будет явно недостаточно.
Несколько слов о названии, призванном объединить статьи подобного рода. Оно, естественно, появилось не случайно, однако и не совсем соответствует сути.
Мои статьи будут рассчитаны на тех, кто уже более-менее знаком с языком C++, но у кого мало опыта в написании программ на нем. Я не буду писать мануалы или «вводные» пособия в духе «ведер», «чашек», «унций» и т.п. Вместо этого я попытаюсь освещать некоторые «узкие» и не всегда и не совсем очевидные места языка C++. Что имеется в виду? Я не раз сталкивался, как на собственном примере, так и при общении с другими программистами, со случаями, когда человек был уверен в своей правоте касательно какой-нибудь возможности языка C++ (иногда довольно длительное время), а в последствие оказывалось, что он глубоко и бесповоротно заблуждался по одному Богу известным причинам.
А причины на самом деле не такие уж и сверхъестественные. Зачастую свою роль играет человеческий фактор. К примеру, прочитав какую-либо книгу для начинающих, в которых, как известно, многие нюансы не объясняются, а иногда даже не упоминаются, дабы упростить восприятие основ языка, читатель додумывает недостающие вещи самостоятельно из соображений a la «по-моему, это логично». Отсюда возникают крупицы недопонимания, иногда приводящие к довольно серьезным ошибкам, ну, а в большинстве случаев просто мешающие успешному прохождению различного рода олимпиад по С++ :)
Как известно, в языке C++ есть возможность объявления подставляемых функций. Это реализуется за счет использования ключевого слова inline. В месте вызова таких функций компилятор сгенерирует не команду call (с предварительным занесением параметров в стек), а просто скопирует тело функции на место вызова с подстановкой соответствующих параметров «по месту» (в случае методов класса компилятор также подставит необходимый адрес this там, где он используется). Естественно inline — это всего лишь рекомендация компилятору, а не приказ, однако в случае, если функция не слишком сложная (достаточно субъективное понятие) и в коде не производятся операции типа взятия адреса функции etc., то скорее всего компилятор поступит именно так, как того ожидает программист.
Подставляемая функция объявляется достаточно просто:
Но речь сейчас не об этом. Мы рассмотрим использование подставляемых методов класса. И начнем с небольшого примера, по вине которого и может возникать данный миф.
Все вы знаете, что определения методов класса можно писать как снаружи класса, так и внутри, и подставляемые функции здесь не исключение. Притом функции, определенные прямо внутри класса, автоматически становятся подставляемыми и ключевое слово inline в таком случае излишне. Рассмотрим пример (использую struct вместо class только для того чтобы не писать public):
В данном примере все отлично, и на экране мы видим заветные строки:
Причем компилятор действительно подставил тела методов в места их вызовов.
Наконец-то мы подобрались к сути сегодняшней статьи. Проблемы начинаются в тот момент, когда мы (соблюдая «хороший стиль программирования») разделяем класс на cpp- и h-файлы:
На стадии линковки получаем ошибку вроде такой (зависит от компилятора — у меня MSVC):
Почему?! Всё достаточно просто: определение подставляемого метода и её вызов находятся в разных единицах трансляции! Не совсем уверен, как именно это устроено внутренне, но я вижу эту проблему так:
если бы это был обычный метод, то в единице трансляции main.obj компилятор бы поставил нечто вроде call XXXXX, а позже уже компоновщик заменил бы XXXXX на конкретный адрес метода A::foo() из единицы трансляции A.obj (конечно же, я всё упростил, но суть не меняется).
В нашем же случае мы имеем дело с inline-методом, то есть вместо вызова компилятор должен подставить непосредственно текст метода. Так как определение находится в другой единице трансляции, компилятор оставляет эту ситуацию на попечение компоновщика. Здесь есть два момента: во-первых, «сколько места должен оставить компилятор для подстановки тела метода?», а во-вторых, в единице трансляции A.obj метод A::foo() нигде не используется, причем метод объявлен как inline (а значит там, где нужно было, компилятор должен был скопировать тело метода), поэтому отдельная скомпилированная версия этого метода в итоговый объектный файл не попадает вообще.
В подтверждение пункта 2 приведу немного дополненный пример:
Теперь всё работает, как и должно, благодаря тому, что inline-метод A::foo() вызывается в неподставляемом методе A::bar(). Если взглянуть на ассемблерный код итогового бинарника, можно увидеть, что, как и раньше, отдельной скомпилированной версии метода foo() нет (то есть у метода нет своего адреса), а тело метода скопировано непосредственно в места вызова.
Как выйти из этой ситуации? Очень просто: подставляемые методы нужно определять непосредственно в header-файле (не обязательно внутри объявления класса). При этом ошибки повторного определения не возникает, так как компилятор говорит компоновщику игнорировать ошибки ODR (One Definition Rule), а компоновщик в свою очередь оставляет только одно определение в результирующем бинарном файле.
Надеюсь, хоть кому-то моя первая статья станет полезной и чуточку поможет достигнуть полного осознания такого странного и местами противоречивого, но, безусловно, интересного языка программирования, как C++. Успехов:)
UPD. В процессе общения с gribozavr была выявлена некоторая неточность касательно ODR в моей статье. Выделил курсивом.
Благодаря вот этому голосованию выяснилось, что на Хабре не хватает статей по такому мощному, но всё менее используемому языку C++. Профессионалам высокого уровня, гуру, магам и волшебникам языка C++, а также тем, кто уже успел оставить этот язык «позади» можно дальше не читать. Сегодня я хочу начать цикл статей, призванных помочь именно новичкам, относительно недавно начавшим изучать этот язык, либо же тем, кто (упаси Боже) читает мало книг, а пытается познавать всё исключительно на практике.
Также я надеюсь привлечь как можно больше авторов к написанию подобных статей, потому как моего опыта здесь будет явно недостаточно.
Лирическое отступление
Несколько слов о названии, призванном объединить статьи подобного рода. Оно, естественно, появилось не случайно, однако и не совсем соответствует сути.
Мои статьи будут рассчитаны на тех, кто уже более-менее знаком с языком C++, но у кого мало опыта в написании программ на нем. Я не буду писать мануалы или «вводные» пособия в духе «ведер», «чашек», «унций» и т.п. Вместо этого я попытаюсь освещать некоторые «узкие» и не всегда и не совсем очевидные места языка C++. Что имеется в виду? Я не раз сталкивался, как на собственном примере, так и при общении с другими программистами, со случаями, когда человек был уверен в своей правоте касательно какой-нибудь возможности языка C++ (иногда довольно длительное время), а в последствие оказывалось, что он глубоко и бесповоротно заблуждался по одному Богу известным причинам.
А причины на самом деле не такие уж и сверхъестественные. Зачастую свою роль играет человеческий фактор. К примеру, прочитав какую-либо книгу для начинающих, в которых, как известно, многие нюансы не объясняются, а иногда даже не упоминаются, дабы упростить восприятие основ языка, читатель додумывает недостающие вещи самостоятельно из соображений a la «по-моему, это логично». Отсюда возникают крупицы недопонимания, иногда приводящие к довольно серьезным ошибкам, ну, а в большинстве случаев просто мешающие успешному прохождению различного рода олимпиад по С++ :)
Итак, миф первый
Как известно, в языке C++ есть возможность объявления подставляемых функций. Это реализуется за счет использования ключевого слова inline. В месте вызова таких функций компилятор сгенерирует не команду call (с предварительным занесением параметров в стек), а просто скопирует тело функции на место вызова с подстановкой соответствующих параметров «по месту» (в случае методов класса компилятор также подставит необходимый адрес this там, где он используется). Естественно inline — это всего лишь рекомендация компилятору, а не приказ, однако в случае, если функция не слишком сложная (достаточно субъективное понятие) и в коде не производятся операции типа взятия адреса функции etc., то скорее всего компилятор поступит именно так, как того ожидает программист.
Подставляемая функция объявляется достаточно просто:
inline void foo(int & _i)
{
_i++;
}
Но речь сейчас не об этом. Мы рассмотрим использование подставляемых методов класса. И начнем с небольшого примера, по вине которого и может возникать данный миф.
Все вы знаете, что определения методов класса можно писать как снаружи класса, так и внутри, и подставляемые функции здесь не исключение. Притом функции, определенные прямо внутри класса, автоматически становятся подставляемыми и ключевое слово inline в таком случае излишне. Рассмотрим пример (использую struct вместо class только для того чтобы не писать public):
// InlineTest.cpp
#include <cstdlib>
#include <iostream>
struct A
{
inline void foo() { std::cout << "A::foo()" << std::endl; }
};
struct B
{
inline void foo();
};
void B::foo()
{
std::cout << "B::foo()" << std::endl;
}
int main()
{
A a; B b;
a.foo();
b.foo();
return EXIT_SUCCESS;
}
В данном примере все отлично, и на экране мы видим заветные строки:
A::foo()
B::foo()
Причем компилятор действительно подставил тела методов в места их вызовов.
Наконец-то мы подобрались к сути сегодняшней статьи. Проблемы начинаются в тот момент, когда мы (соблюдая «хороший стиль программирования») разделяем класс на cpp- и h-файлы:
// A.h
#ifndef _A_H_
#define _A_H_
class A
{
public:
inline void foo();
};
#endif // _A_H_
// A.cpp
#include "A.h"
#include <iostream>
void A::foo()
{
std::cout << "A::foo()" << std::endl;
}
// main.cpp
#include <cstdlib>
#include <iostream>
#include "A.h"
int main()
{
A a;
a.foo();
return EXIT_SUCCESS;
}
На стадии линковки получаем ошибку вроде такой (зависит от компилятора — у меня MSVC):
main.obj: error LNK2001: unresolved external symbol «public: void __thiscall A::foo (void)» (? foo@A@@QAEXXZ)
Почему?! Всё достаточно просто: определение подставляемого метода и её вызов находятся в разных единицах трансляции! Не совсем уверен, как именно это устроено внутренне, но я вижу эту проблему так:
если бы это был обычный метод, то в единице трансляции main.obj компилятор бы поставил нечто вроде call XXXXX, а позже уже компоновщик заменил бы XXXXX на конкретный адрес метода A::foo() из единицы трансляции A.obj (конечно же, я всё упростил, но суть не меняется).
В нашем же случае мы имеем дело с inline-методом, то есть вместо вызова компилятор должен подставить непосредственно текст метода. Так как определение находится в другой единице трансляции, компилятор оставляет эту ситуацию на попечение компоновщика. Здесь есть два момента: во-первых, «сколько места должен оставить компилятор для подстановки тела метода?», а во-вторых, в единице трансляции A.obj метод A::foo() нигде не используется, причем метод объявлен как inline (а значит там, где нужно было, компилятор должен был скопировать тело метода), поэтому отдельная скомпилированная версия этого метода в итоговый объектный файл не попадает вообще.
В подтверждение пункта 2 приведу немного дополненный пример:
// A.h
#ifndef _A_H_
#define _A_H_
class A
{
public:
inline void foo();
void bar();
};
#endif // _A_H_
// A.cpp
#include "A.h"
#include <iostream>
void A::foo()
{
std::cout << "A::foo()" << std::endl;
}
void A::bar()
{
std::cout << "A::bar()" << std::endl;
foo();
}
// main.cpp
#include <cstdlib>
#include <iostream>
#include "A.h"
int main()
{
A a;
a.foo();
return EXIT_SUCCESS;
}
Теперь всё работает, как и должно, благодаря тому, что inline-метод A::foo() вызывается в неподставляемом методе A::bar(). Если взглянуть на ассемблерный код итогового бинарника, можно увидеть, что, как и раньше, отдельной скомпилированной версии метода foo() нет (то есть у метода нет своего адреса), а тело метода скопировано непосредственно в места вызова.
Как выйти из этой ситуации? Очень просто: подставляемые методы нужно определять непосредственно в header-файле (не обязательно внутри объявления класса). При этом ошибки повторного определения не возникает, так как компилятор говорит компоновщику игнорировать ошибки ODR (One Definition Rule), а компоновщик в свою очередь оставляет только одно определение в результирующем бинарном файле.
Заключение
Надеюсь, хоть кому-то моя первая статья станет полезной и чуточку поможет достигнуть полного осознания такого странного и местами противоречивого, но, безусловно, интересного языка программирования, как C++. Успехов:)
UPD. В процессе общения с gribozavr была выявлена некоторая неточность касательно ODR в моей статье. Выделил курсивом.