Что такое «связывание» и что делает его поздним?

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

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

По определению, компилятор – это такое устройство, которое принимает текст, написанный на одном языке, и выдает код на другом языке, «который означает то же самое». Я, например, разрабатываю компилятор, который принимает на вход текст на языке 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 завершится неудачно, и мы можем сказать вам об этом во время компиляции. И компилятор на самом деле выполняет подобный анализ; а как именно – это хорошая тема для еще одного разговора.

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

Оригинал статьи