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

Приведенный пример в определенной мере отражает смысл применения раннего и позднего связывания, соответственно. Очевидно, что для данного примера первый вариант оптимален. Во втором случае мы слишком много всего предусмотрели, но нам это не понадобилось. С другой стороны, если по дороге на рынок мы решим, что апельсины нам не нужны и решим купить 10 кг. яблок, то в первом случае мы уже не сможем этого сделать. Во втором же случае - легко.

А теперь рассмотрим этот пример с точки зрения программирования. При применении раннего связывания, мы как бы говорим компилятору: "Я точно знаю, чего я хочу. Поэтому жестко(статически) связывай все вызовы функций". При применении механизма позднего связывания мы как бы говорим компилятору: "Я пока не знаю чего я хочу. Когда придет время, я сообщу что и как я хочу".

Таким образом, во время раннего связывания вызывающий и вызываемый методы связываются при первом удобном случае, обычно при компиляции.

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

Очевидно, что скорость и эффективность при раннем связывании выше, чем при использовании позднего связывания. В то же время, позднее связывание обеспечивает некоторую универсальность связывания.

Наконец-то мы добрались и до самих виртуальных функций и методов. К сожалению, для иллюстрации виртуальных методов достаточно сложно провести какую-либо аналогию с физическим миром. Поэтому сразу рассмотрим этот вопрос с точки зрения программирования.

Итак, для чего же применяются виртуальные методы. Виртуальные методы существуют для того, чтобы "наследник" вел себя отлично от "предка", сохраняя при этом свойство совместимости с ним.

Приведем определение виртуальных методов:

Виртуальный метод - это метод, который, будучи описан в потомках, замещает собой соответствующий метод везде, даже в методах, описанных для предка, если он вызывается для потомка .

Адрес виртуального метода известен только в момент выполнения программы. Когда происходит вызов виртуального метода, его адрес берется из таблицы виртуальных методов своего класса. Таким образом вызывается то, что нужно.

Преимущество применения виртуальных методов заключается в том, что при этом используется именно механизм позднего связывания, который допускает обработку объектов, тип которых неизвестен во время компиляции.

Для иллюстрации применения виртуальных методов приведу пример на языке С++ , который я позаимствовал из одного C++ Tutorial . Даже если вы не очень разбираетесь в этом языке, надеюсь, что мои пояснения хоть как-то объяснят его смысл.

#include // подключение стандартной библиотек С++, в // которой описаны некоторый функции, применяемые в программе class vehicle // класс "транспортное средство" { int wheels; float weight; public: // начало публичного(открытого) раздела класса virtual void message(void) {cout message(); // вызываем метод message объекта delete unicycle; // удаляем объект unicycle // Все последующие блоки по 3 строки абсолютно идентичны первому // блоку с той лишь разницей, что изменяется класс создаваемого объекта // на car, truck, boat unicycle = new car; unicycle->message(); delete unicycle; unicycle = new truck; unicycle->message(); delete unicycle; unicycle = new boat; unicycle->message(); delete unicycle; }

Результаты работы программы(вывод на экран):

Транспортное средство Легковая машина Транспортное средство Лодка

Рассмотрим приведенный пример. У нас есть три класса car , truck и boat , которые являются производными от базового класса vehicle . В базовом классе vehicle описана виртуальная функция message . В двух из трех классов(car , boat ) также описаны свои функции message , а в классе truck нет описания своей функции message . Все строки, к которым я не приводил комментарии, не имеют принципиального для данного примера значения. Теперь пробежимся по основному блоку программы - функции main() . Описываем переменную unicycle , как указатель на объект типа vehicle . Не буду вдаваться в подробности, почему именно указатель на объект. Так надо. В данном случае воспринимайте работу с указателем, как с самим объектом. Подробности работы с указателями можно найти в описаниях конкретного языка ООП. Затем, создаем объект класса vehicle , переменная unicycle указывает на этот объект. После этого вызываем метод message объекта unicycle , а в следующей строке удаляем этот объект. В следующих трех блоках по 3 строки проводим аналогичные операции, с той лишь разницей, что работаем с объектами классов car , truck , boat . Применение указателя позволяет нам использовать один и этот же указатель для всех производных классов. Нас интересует вызов функции message для каждого из объектов. Если бы мы не указали, что функция message класса vehicle является виртуальной(virtual ), то компилятор статически(жестко) связал бы любой вызов метода объекта указателя unicycle с методом message класса vehicle , т.к. при описании мы сказали, что переменная unicycle указывает на объект класса vehicle . Т.е. произвели бы раннее связывание. Результатом работы такой программы был бы вывод четырех строк "Транспортное средство". Но за счет применения виртуальной функции в классе мы получили несколько другие результаты.

При работе с объектами классов car и boat вызываются их собственные методы message , что и подтверждается выводом на экран соответствующих сообщений. У класса truck нет своего метода message , по этой причине производится вызов соответствующего метода базового класса vehicle .

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

Виртуальные методы описываются с помощью ключевого слова virtual в базовом классе. Это означает, что в производном классе этот метод может быть замещен методом, более подходящим для этого производного класса. Объявленный виртуальным в базовом классе, метод останется виртуальным для всех производных классов. Если в производном классе виртуальный метод не будет переопределен, то при вызове будет найден метод с таким именем вверх по иерархии классов (т.е. в базовом классе).

Последнее, о чем необходимо рассказать, говоря о виртуальных функциях, это понятие абстрактных классов. Но мы это рассмотрим на следующем шаге.

Статье я рассказывал, с какой не всем известной особенностью можно столкнуться при работе с подставляемыми функциями. Статья породила как несколько существенных замечаний, так и многостраничные споры (и даже холивары), начавшиеся с того, что inline-функции вообще лучше не использовать, и перешедшие в стандартную тему C vs. C++ vs. Java vs. C# vs. PHP vs. Haskell vs. …

Сегодня пришла очередь виртуальных функций. И, во-первых, я сразу оговорюсь, что статья моя (в принципе, как и предыдущая) ни в коей мере не претендует на полноту изложения. А во-вторых, как и раньше, эта статья не для профессионалов. Она будет полезна тем, кто уже нормально разбирается в основах C++, но имеет недостаточно опыта, либо же тем, кто не любит читать книжек.

Надеюсь, все знают, что такое виртуальные функции и как они используются, так как объяснять это уже не моя задача. Уверен, что RFry в цикле своих статей о C++ рано или поздно доберется и до них.

Если в материале про inline-методы миф был не совсем очевиден, то в этом - напротив. Собственно, перейдем к «мифу».

Виртуальные функции и ключевое слово virtual
К моему удивлению, я очень часто сталкивался и сталкиваюсь с людьми (да что там говорить, я и сам был таким же), которые считают, что ключевое слово virtual делает функцию виртуальной только на один уровень иерархии . Объясню, что имеется в виду, на примере:

  1. #include
  2. #include
  3. using std::cout;
  4. using std::endl;
  5. struct A
  6. virtual ~A() {}
  7. << "A::foo()" << endl; }
  8. << "A::bar()" << endl; }
  9. void baz() const { cout << "A::baz()" << endl; }
  10. struct B: public A
  11. virtual void foo() const { cout << "B::foo()" << endl; }
  12. void bar() const { cout << "B::bar()" << endl; }
  13. void baz() const { cout << "B::baz()" << endl; }
  14. struct C: public B
  15. virtual void foo() const { cout << "C::foo()" << endl; }
  16. virtual void bar() const { cout << "C::bar()" << endl; }
  17. void baz() const { cout << "C::baz()" << endl; }
  18. int main()
  19. cout << "pA is B:" << endl;
  20. A * pA = new B;
  21. pA->foo();
  22. pA->bar();
  23. pA->baz();
  24. delete pA;
  25. cout << "\npA is C:" << endl;
  26. pA = new C;
  27. pA->foo(); pA->bar(); pA->baz();
  28. delete pA;
  29. return EXIT_SUCCESS;

Итак, имеем простую иерархию классов. В каждом классе определены 3 метода: foo() , bar() и baz() . Рассмотрим неверную логику людей, которые находятся под действием мифа :
когда указатель pA указывает на объект типа B имеем вывод:

pA is B:
B::foo() // потому что в родительском классе A метод foo() помечен как virtual
B::bar() // потому что в родительском классе A метод bar() помечен как virtual
A::baz() // потому что в родительском классе A метод baz() не помечен как virtual

когда указатель pA указывает на объект типа С имеем вывод:
pA is C:
С::foo() // потому что в родительском классе B метод foo() помечен как virtual
B::bar() // потому что в родительском классе B метод bar() не помечен как virtual,
// но он помечен как virtual в классе A, указатель на который мы используем
A::baz() // потому что в классе A метод baz() не помечен как virtual

С невиртуальной функцией baz() всё и так ясно. А вот с логикой вызова виртуальных функций есть неувязочка. Думаю, не стоит говорить, что на самом деле вывод будет следующим:

pA is B:
B::foo()
B::bar()
A::baz()

PA is C:
C::foo()
C::bar()
A::baz()

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

Чтобы понять, почему так происходит, нужно разобраться, как именно работает механизм виртуальных функций.

Раннее и позднее связывание. Таблица виртуальных функций
Связывание - это сопоставление вызова функции с вызовом. В C++ все функции по умолчанию имеют раннее связывание , то есть компилятор и компоновщик решают, какая именно функция должна быть вызвана, до запуска программы. Виртуальные функции имеют позднее связывание , то есть при вызове функции нужное тело выбирается на этапе выполнения программы.

Встретив ключевое слово virtual, компилятор помечает, что для этого метода должно использоваться позднее связывание: для начала он создает для класса таблицу виртуальных функций, а в класс добавляет новый скрытый для программиста член - указатель на эту таблицу. (На самом деле, насколько я знаю, стандарт языка не предписывает, как именно должен быть реализован механизм виртуальных функций, но реализация на основе виртуальной таблицы стала стандартом де факто.). Рассмотрим этот пруфкод:

  1. #include
  2. #include
  3. struct Empty {};
  4. struct EmptyVirt { virtual ~EmptyVirt(){} };
  5. struct NotEmpty { int m_i; };
  6. struct NotEmptyVirt
  7. virtual ~NotEmptyVirt() {}
  8. int m_i;
  9. struct NotEmptyNonVirt
  10. void foo() const {}
  11. int m_i;
  12. int main()
  13. std::cout << sizeof (Empty) << std::endl;
  14. std::cout << sizeof (EmptyVirt) << std::endl;
  15. std::cout << sizeof (NotEmpty) << std::endl;
  16. std::cout << sizeof (NotEmptyVirt) << std::endl;
  17. std::cout << sizeof (NotEmptyNonVirt) << std::endl;
  18. return EXIT_SUCCESS;
* This source code was highlighted with Source Code Highlighter .

Вывод может отличаться в зависимости от платформы, но в моем случае (Win32, msvc2008) он был следующим:

Что можно понять из этого примера. Во-первых, размер «пустого» класса всегда больше нуля, потому что компилятор специально вставляет в него фиктивный член. Как пишет Эккель, «представьте процесс индексирования в массиве объектов нулевого размера, и все станет ясно» ;) Во-вторых, мы видим, что размер «непустого» класса NotEmptyVirt при добавлении в него виртуальной функции увеличился на стандартный размер указателя на void; а в «пустом» классе EmptyVirt фиктивный член, который компилятор ранее добавлял для приведения класса к ненулевому размеру, был заменен на указатель. В то же время добавление невиртуальной функции в класс на размер не влияет (спасибо nullbie за совет). Имя указателя на таблицу отличается в зависимости от компилятора. К примеру, компилятор Visual Studio 2008 называет его __vfptr, а саму таблицу ‘vftable’ (кто не верит, может посмотреть в отладчике:) В литературе указатель на таблицу виртуальных функций принято называть VPTR, а саму таблицу VTABLE, поэтому я буду придерживаться таких же обозначений.

Что представляет собой таблица виртуальных функций и для чего она нужна? Таблица виртуальных функций хранит в себе адреса всех виртуальных методов класса (по сути, это массив указателей), а также всех виртуальных методов базовых классов этого класса.

Таблиц виртуальных функций у нас будет столько, сколько есть классов, содержащих виртуальные функции - по одной таблице на класс. Объекты каждого из классов содержат именно указатель на таблицу, а не саму таблицу! Вопросы на эту тему любят задавать преподаватели, а также те, кто проводит собеседования. (Примеры каверзных вопросов, на которых можно подловить новичков: «если класс содержит таблицу виртуальных функций, то размер объекта класса будет зависеть от количества виртуальных функций, содержащихся в нем, верно?»; «имеем массив указателей на базовый класс, каждый из которых указывает на объект одного из производных классов - сколько у нас будет таблиц виртуальных функций?» и т.д.).

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

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

При вызове функции через адрес базового класса (читайте - через указатель на базовый класс) компилятор сначала должен по указателю VPTR обратиться к таблице виртуальных функций класса, а из неё получить адрес тела вызываемой функции, и только после этого делать call.

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

Думаю, на примере все станет понятнее. Рассмотрим следующую иерархию:

В данном случае получим две таблицы виртуальных функций:

Base
0
Base::foo()
1 Base::bar()
2 Base::baz()

и
Inherited
0
Base::foo()
1 Inherited::bar()
2 Base::baz()
3 Inherited::qux()

Как видим, в таблице производного класса адрес второго метода был заменен на соответствующий переопределенный. Пруфкод:

  1. #include
  2. #include
  3. using std::cout;
  4. using std::endl;
  5. struct Base
  6. Base() { cout << "Base::Base()" << endl; }
  7. virtual ~Base() { cout << "Base::~Base()" << endl; }
  8. virtual void foo() { cout << "Base::foo()" << endl; }
  9. virtual void bar() { cout << "Base::bar()" << endl; }
  10. virtual void baz() { cout << "Base::baz()" << endl; }
  11. struct Inherited: public Base
  12. Inherited() { cout << "Inherited::Inherited()" << endl; }
  13. virtual ~Inherited() { cout << "Inherited::~Inherited()" << endl; }
  14. virtual void bar() { cout << "Inherited::bar()" << endl; }
  15. virtual void qux() { cout << "Inherited::qux()" << endl; }
  16. int main()
  17. Base * pBase = new Inherited;
  18. pBase->foo();
  19. pBase->bar();
  20. pBase->baz();
  21. //pBase->qux(); // Ошибка
  22. delete pBase;
  23. return EXIT_SUCCESS;
* This source code was highlighted with Source Code Highlighter .

Что происходит при запуске программы? Вначале объявляем указатель на объект типа Base, которому присваиваем адрес вновь созданного объекта типа Inherited. При этом вызывается конструктор Base, инициализирует VPTR адресом VTABLE класса Base, а затем конструктор Inherited, который перезаписывает значение VPTR адресом VTABLE класса Inherited. При вызове pBase->foo() , pBase->bar() и pBase->baz() компилятор через указатель VPTR достает фактический адрес тела функции из таблицы виртуальных функций. Как это происходит? Вне зависимости от конкретного типа объекта компилятор знает, что адрес функции foo() находится на первом месте, bar() - на втором, и т.д. (как я и говорил, в порядке объявления функций). Таким образом, для вызова, к примеру, функции baz() он получает адрес функции в виде VPTR+2 - смещение от начала таблицы виртуальных функций, сохраняет этот адрес и подставляет в команду call. По этой же причине, вызов pBase->qux() приводит к ошибке: несмотря на то, что фактический тип объекта Inherited, когда мы присваиваем его адрес указателю на Base, происходит восходящее приведение типа, а в таблице VTABLE класса Base никакого четвертого метода нет, поэтому VPTR+3 указывало бы на «чужую» память (к счастью, такой код даже не компилируется).

Вернемся к мифу. Становится очевидным тот факт, что при таком подходе к реализации виртуальных функций невозможно сделать так, чтобы функция была виртуальной только на один уровень иерархии.

Также становится понятно, почему виртуальные функции работают только при обращении по адресу объекта (через указатели либо через ссылки). Как я уже сказал, в этой строке
Base * pBase = new Inherited;
происходит повышающее приведение типа: Inherited* приводится к Base*, но в любом случае указатель всего лишь хранит адрес «начала» объекта в памяти. Если же повышающее приведение производить непосредственно для объекта, то он фактически «обрезается» до размера объекта базового класса. Поэтому логично, что для вызова функций «через объект» используется раннее связывание - компилятор и так «знает» фактический тип объекта.

Собственно, это всё. Жду комментариев. Спасибо за внимание.

P.S. Данная статья помечена грифом «Гарантия Скора»
(Skor, если ты это читаешь, это для тебя;)

P.P.S. Да, забыл сказать… Джависты сейчас начнут кричать, что в Java по умолчанию все функции виртуальные.
_________
Текст подготовлен в

Чтобы выяснить, в чем состоит различие между ранним (статическим) и поздним (динамическим) связыванием в Java, нужно сначала понять, что такое это самое связывание . Связывание означает наличие связи между ссылкой и кодом. Например, переменная, на которую вы ссылаетесь, привязана к коду, в котором она определена. Аналогично, вызываемый метод привязан к месту в коде, где он определен.

Существует два типа связывания методов в языке Java: ранее связывание (его ещё называют статическим) и позднее (соответственно, динамическое) связывание . Вызов метода в Java означает, что этот метод привязывается к конкретному коду или в момент компиляции, или во время выполнения, при запуске программы и создании объектов. Можно понять из названия, статическое связывание носит более статический характер, так как происходит во время компиляции, то есть код «знает», какой метод вызывать после компиляции исходного кода на Java в файлы классов. А поскольку это относится к ранней стадии жизненного цикла программы, то называется также ранним связыванием (early binding). С другой стороны, динамическое связывание происходит во время выполнения, после запуска программы виртуальной машиной Java. В этом случае то, какой метод вызвать, определяется конкретным объектом, так что в момент компиляции информация недоступна, ведь объекты создаются во время выполнения. А поскольку это происходит на поздней стадии жизненного цикла программы, то называется в языке Java поздним связыванием (late binding). Давайте рассмотрим еще несколько отличий, чтобы лучше разобраться с этим, а, кроме того, мочь ответить на этот очень популярный вопрос, который задают на собеседованиях по Java.

Раннее и позднее связывание в Java

Существует множество различий статического и динамического связывания в языке Java, но важнейшее – то, как их использует JVM. Задумывались ли вы когда-нибудь, каким образом JVM решает, какой метод вызвать, если в области видимости содержится более одного метода с одним именем? Если вы когда-либо использовали перегрузку или переопределение методов, то знаете, что в Java может быть несколько методов с одним именем. В случае с Java виртуальная машина JVM использует как статическое, так и динамическое связывание для выбора нужного метода.

Пример статического и динамического связывания в Java

В этой программе вы увидите, что привязка виртуальных методов не происходит во время компиляции при помощи статического связывания, поскольку в этом случае вызывался бы метод из суперкласса, как происходит со статическими методами, которые связываются рано. Если будет вызван метод из подкласса, то для связывания функции использовался конкретный объект во время выполнения, а, следовательно, для связывания виртуальных функций используется динамическое связывание. public class Main { public static void main (String args) { // Пример статического и динамического связывания в Java Insurance current = new CarInsurance () ; // Динамическое связывание на основе объекта int premium = current. premium () ; // Статическое связывание на основе класса String category = current. category () ; System. out. println ("premium: " + premium) ; System. out. println ("category: " + category) ; } } class Insurance { public static final int LOW = 100 ; public int premium () { return LOW; } public static String category () { return "Insurance" ; } } class CarInsurance extends Insurance { public static final int HIGH = 200 ; public int premium () { return HIGH; } public static String category () { return "Car Insurance" ; } } Результаты выполнения: premium : 200 category : Insurance Как вы видите, вызов метода premium() привел к выполнению метода из подкласса, в то время как вызов метода category() привел к выполнению метода суперкласса. Это происходит из-за того, что premium() – виртуальный метод, который разрешается при помощи позднего связывания, в то время как category() – статический метод, который разрешается при помощи статического связывания во время компиляции по имени класса.
Интересно читать о Java? Вступайте в группу !

Различия между ранним и поздним связыванием в языке Java

Теперь, когда вы разобрались и понимаете, как в языке Java связываются вызовы методов и как функционирует статическое и динамическое связывание, давайте еще раз перечислим ключевые различия между ранним и поздним связыванием в языке Java:
  1. Статическое связывание происходит во время компиляции, а динамическое – во время выполнения.

  2. Поскольку статическое связывание происходит на ранней стадии жизненного цикла программы, его называют ранним связыванием. Аналогично, динамическое связывание называют также поздним связыванием, поскольку оно происходит позже, во время работы программы.

  3. Статическое связывание используется в языке Java для разрешения перегруженных методов, в то время как динамическое связывание используется в языке Java для разрешения переопределенных методов.

  4. Аналогично, приватные, статические и терминальные методы разрешаются при помощи статического связывания, поскольку их нельзя переопределять, а все виртуальные методы разрешаются при помощи динамического связывания.

  5. В случае статического связывания используются не конкретные объекты, а информация о типе, то есть для обнаружения нужного метода используется тип ссылочной переменной. С другой стороны, при динамическом связывании для нахождения нужного метода в Java используется конкретный объект.
Вот неплохое упражнение, основанное на понятиях статического и динамического связывания в языке Java. Сможете ли вы ответить на вопрос: "Что будет выведено при выполнении следующей программы?" Что выведет эта программа? Collection , Set или HashSet ? Вот и все, что мы хотели рассказать вам о различиях между ранним (статическим) и поздним (динамическим) связыванием в языке Java. Это один из лучших вопросов для телефонного собеседования по языку Java, поскольку оно предоставляет немало возможностей проверки глубины знаний кандидата. Всегда помните, что приватные , статические и final-методы связываются при помощи статического связывания , а виртуальные – динамического . Аналогично, лучший пример статического связывания – перегрузка методов, а переопределение – динамического.

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

Прежде всего, что такое «связывание»? Мы не сможем понять, что означает позднее связывание, если мы не знаем, что вообще означает термин «связывание».

По определению, компилятор – это такое устройство, которое принимает текст, написанный на одном языке, и выдает код на другом языке, «который означает то же самое». Я, например, разрабатываю компилятор, который принимает на вход текст на языке C# и выдает CIL (*). Все важные задачи, выполняемые компилятором можно разделить на три крупные группы:

  • Синтаксический анализ входного текста
  • Семантический анализ синтаксиса
  • Генерация выходного текста – в этой статье этот этап нам не интересен

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

Затем семантический анализатор берет результаты синтаксического анализатора и связывает значения различных синтаксических элементов. Например, когда вы пишите:

class X {}
class B {}
class D: B
{
public static void X() { }
public static void Y() { X(); }
}

то синтаксический анализатор определяет наличие трех классов, что один из них содержит два метода, второй метод содержит оператор, который является выражением вызова метода. Семантический анализатор определяет, что X в выражении X(); ссылается на метод D.X(), а не, скажем, на тип X, объявленный выше. Это и есть пример «связывания» в наиболее широком смысле этого слова: связывание – это ассоциация синтаксического элемента, содержащего имя метода, с логической частью программы .

Когда речь заходит о «раннем» или «позднем» «связывании», то речь всегда идет об определении имени для вызова метода. Однако, с моей точки зрения это определение слишком строгое. Я буду использовать термин «связывание» при описании процесса определения семантическим анализатором компилятора, что класс D наследует класс B и что имя «B» связано с именем класса.

Более того, я буду использовать термин «связывание» для описания и других видов анализа. Если у вас в программе есть выражение 1 * 2 + 1.0, тогда я могу сказать, что оператор «+» связан со встроенным оператором, который принимает два числа с плавающей запятой, складывает их и возвращает третье число. Обычно люди не думают о связи имени «+» с определенным методом, но я, все же, считаю это «связыванием».

Говоря еще менее строго, я могу использовать термин «связывание» для нахождения ассоциации типов с выражениями, которые не используют имя этого типа напрямую. Если говорить неформально, то в приведенном выше примере выражение 1 * 2 «связано» с типом int, хотя, очевидно, имя этого типа в нем не указано. Синтаксическое выражение строго связано с этим семантическим элементом, хотя и не использует соответствующее имя напрямую.

Так что, говоря в общем случае, я бы сказал, что «связывание» – это любая ассоциация некоторого фрагмента синтаксического дерева с некоторым логическим элементом программы. (**)

Тогда в чем разница между «ранним» и «поздним» связыванием? Люди часто говорят об этих понятиях так, будто это взаимоисключающий выбор: связывание либо раннее, либо позднее. Как мы вскоре увидим, это не так; некоторые виды связывания полностью ранние, некоторые частично ранние и частично – поздние, а некоторые – и правда, полностью поздние. Но прежде чем переходить к этому, давайте рассмотрим, по отношению к чему связывание бывает ранним или поздним?

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

Какое из этих видов связывания лучше? Очевидно, что ни один из вариантов не является однозначно лучше другого; если бы один из вариантов всегда превосходил другой, то мы бы с вами ничего сейчас не обсуждали. Преимущество раннего связывания в том, что мы можем быть уверены в отсутствии ошибок времени выполнения; недостатком же является отсутствие гибкости позднего связывания. Раннее связывание предполагает, что вся информация, необходимая для принятия правильного решения будет известна перед выполнением программы; но иногда эта информация не доступна до момента выполнения.

Я уже говорил, что связывание образует спектр от раннего до позднего. Давайте рассмотрим некоторые примеры на языке C#, которые покажут, как мы можем перейти от раннего связывания к позднему.

Мы начали с примера вызова статического метода Х. Этот анализ однозначно является ранним. Нет никакого сомнения в том, что при вызове метода Y, будет вызван метод D.X. Никакая часть этого анализа не откладывается до времени выполнения, поэтому данный вызов будет однозначно успешным.

Теперь, давайте рассмотрим следующий пример:

class B
{
public void M(double x) {}
public void M(int x) {}
}
class C
{
public static void X(B b, int d) { b.M(d); }
}

Теперь у нас меньше информации. Мы выполняем много раннего связывания; мы знаем, что переменная b типа B, и что вызывается метод B.M(int). Но, в отличие от предыдущего примера, у нас нет никаких гарантий компилятора, что вызов будет успешным, поскольку переменная b может быть null. По сути, мы откладываем до времени выполнения анализ того, будет ли приемник вызова валидным или нет. Многие не рассматривает это решение, как «связывание», поскольку мы не связываем синтаксис с программным элементом . Давайте сделаем вызов внутри метода C немного более поздним, путем изменения класса B:

class B
{
public virtual void M(double x) {}
public virtual void M(int x) {}
}

Теперь мы выполняем часть анализа во время компиляции; мы знаем, что будет вызван виртуальный метод B.M(int). Мы знаем, что вызов метода будет успешен, в том плане, что такой метод существует. Но мы не знаем, какой именно метод будет вызван во время выполнения! Это может быть переопределенный метод в наследнике; может быть вызван совершенно другой код, определенный в другой части программы. Диспетчеризация виртуальных методов является формой позднего связывания; решение о том, какой метод связан с синтаксической конструкцией b.M(d) частично принимается компилятором, а частично – во время выполнения.

А как насчет такого примера?

class C
{
public static void X(B b, dynamic d) { b.M(d); }
}

Теперь связывание практически полностью отложено до времени выполнения. В этом случае компилятор генерирует код, который говорит динамической среде времени выполнения (Dynamic Language Runtim), что статический анализ определил, что статическим типом переменной b является класс B и что вызываемой метод называется M, но реальное разрешение перегрузки для определения метода B.M(int) или B.M(double) (или никакого из них, если d, например, будет типа string) будет выполнено во время выполнения на основе этой информации. (***)

class C
{
public static void X(dynamic b, dynamic d) { b.M(d); }
}

Теперь, на этапе компиляции определяется лишь то, что для некоторого типа вызывается метод с именем M. Это практически наиболее позднее связывание, но, на самом деле, мы можем пойти еще дальше:

class C
{
public static void X(object b, object d, string m, BindingFlags f)
{
b.GetType().GetMethod(m, f).Invoke(b, d);
}
}

Теперь весь анализ выполняется во время позднего связывания; мы даже не знаем, какое имя мы собираемся связывать с вызываемым методом. Все, что мы можем знать, так это то, что автор X ожидает, что в переданном объекте b есть метод, имя которого определяет m, соответствующий флагам, переданным в f, принимающий аргументы, переданные в d. В этом случае мы ничего не можем сделать во время компиляции. (****)

(*) Конечно же, результат кодируется в двоичный формат, а не в читабельный для человека CIL формат.

(**) Вы можете спросить: являются ли «связывание» и «семантический анализ» синонимами; конечно, семантический анализ – это не более чем ассоциация синтаксических элементов с их значениями! Связывание является большей частью фазы семантического анализа компилятора, но есть много других форм анализа, которые нужно выполнить уже после того, как тела методов полностью «связаны». Например, анализ определенного присваивания (definite assignment) никак нельзя назвать «связыванием»; он не является ассоциацией синтаксических элементов с конкретными элементами программы. Скорее, этот анализ связывает лексические места с фактами о программных элементах, типа «локальная переменная blah не является определенно присвоенной в начале этого блока». Аналогично, оптимизация арифметических выражений является формой семантического анализа и явно не относится к «связыванию».

(***) Компилятор все еще может выполнить значительную часть статического анализа. Предположим, что класс B является закрытым (sealed) классом без методов с именем M. Даже при наличии динамических аргументов мы уже во время компиляции знаем, что связывание с методом M завершится неудачно, и мы можем сказать вам об этом во время компиляции. И компилятор на самом деле выполняет подобный анализ; а как именно – это хорошая тема для еще одного разговора.

(****) В некотором смысле этот пример является хорошим контрпримером моего определения связывания; мы даже не связываем синтаксические элементы с методом; мы связываем содержимое строки с методом.

2

Say не было никакой функции Здравствуйте, и мы просто называемся ob.display в основном, то она вызывает функцию отображения класса B, а не класс А.

Вызов функции display() устанавливается один раз компилятором как версия, определенная в базовом классе. Это называется статическим разрешением вызова функции или статической привязкой - вызов функции фиксируется перед выполнением программы. Это также иногда называют ранним связыванием, потому что функция display() задается во время компиляции программы.

Теперь, как он может вызвать функцию отображения производного класса без использования виртуального ключевого слова (позднего связывания) перед функцией отображения в базовом классе?

Теперь в этой программе, передавая объект как вызов по значению, вызов по указателю и вызов по ссылке на функцию Hello отлично работают. Теперь, если мы используем Полиморфизм и хотим отобразить функцию-член производного класса, если он вызывается, мы должны добавить ключевое слово virtual перед функцией отображения в базе. Если передать значение объекта при вызове с помощью указателя и вызова по ссылке это вызов функции в производном классе, но если передать объект по значению он не почему это так?>

Class A { public: void display(); // virtual void display() { cout << "Hey from A" <display() } int main() { B obj; Hello(obj); // obj //&ob return 0; }

  • 2 ответа
  • Сортировка:

    Активность

4

сейчас как он может вызвать функцию отображения производного класса без использования ключевого слова virtual (поздняя привязка) перед функцией отображения в базовом классе?

Не виртуальная функция просто разрешается компилятором в соответствии с статическим типом объекта (или ссылки или указателя), который он вызывает. Таким образом, данный объект производного типа, а также ссылку на его подъобекта:

B b; A & a = b;

вы получите разные результаты от вызова невиртуальный функции:

B.display(); // called as B a.display(); // called as A

Если вы знаете, вещественный тип, то вы можете указать, что вы хотите назвать эту версию:

Static_cast(a).display(); // called as B

но что бы ужасно неправильно, если объект a относится к не имеет типа B .

Теперь, если мы используем Полиморфизм и хотим отобразить функцию-член производного класса, если он вызывается, мы должны добавить виртуальное ключевое слово перед функцией отображения в базе.

Исправить. Если вы сделаете функцию виртуальной, то она будет разрешена во время выполнения в соответствии с динамическим типом объекта, даже если вы используете другой тип ссылки или указатель для доступа к ней. Таким образом, оба вышеуказанных примера назвали бы его B .

Если мы передаем значение объекта по вызову указателем и вызываем по ссылке, он вызывает функцию в производном классе, но если мы передаем объект по значению, это не значит, почему это так?

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

0

wikipedia говорит, что нарезка объектов происходит потому, что нет места для хранения дополнительных членов производного класса в суперклассе, поэтому он срезан. Почему нарезка объектов не происходит, если мы передаем ее ссылкой или указателем? Почему суперкласс получает дополнительное пространство для его хранения? -