C++ MythBusters. Миф о подставляемых функциях

Posted: воскресенье, 1 февраля 2009 г.
Здравствуйте.

Профессионалам высокого уровня, гуру, магам и волшебникам языка 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++. Успехов:)
blog comments powered by Disqus