Подстановка параметров

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

void swap(float x, float y)

{

x += y; y = x – y; x -= y;

}

Здесь мы проявили смекалку и оптимизировали процедуру[27], сэкономив вспомогательную переменную, по сравнению с

float tmp = x; x = y; y = tmp;

Ожидается, что, если имеются переменные A и B c текущими значениям 1 и 2 соответственно,

float A = 1, B = 2;

то после вызова

swap(A,B);

значениями станут 2 и 1. Однако, на самом деле A и B останутся неизменными именно ввиду того, что все операции внутри тела swap выполнялись над локальными объектами x и y, которые после того, как в им были присвоены начальные значения, не имеют никакого отношения к A и B.

На первый взгляд получается, что при передаче параметров по значению единственным способом осуществить побочный эффект является изменение глобальных переменных. Нам необходимо, чтобы в ходе выполнения swap переменные x и y означали A и B соответственно, и любое присваивание, скажем, x означала присваивание A. Такой способ передачи параметров называется по ссылке. В языке C он может быть реализован следующим образом:

void swap(float * x, float * y)

{

*x += *y; *y = *x–*y; *x -= *y;

}

а при вызове по значению передавать указатель на объект:

swap(&A,&B);

Состояние после *x += *y; отражено на следующем рисунке:

 

A
B
x
y
3
2
 
 

 


Указанный метод практически в точности отражает семантику передачи параметров по ссылке, за исключением того факта, что значение формального параметра, скажем x, может быть изменено в процессе выполнения функции и он будет указывать уже на другой объект. Кроме того, обилие операций разыменования явно не улучшает читаемость программы.

Некоторые языки программирования - Паскаль, Visual Basic, C++ и др. - поддерживают как передачу параметров по значению, так и по ссылке, причём последний способ является умолчательным. В языке Фортран параметры всегда передаются по ссылке, поскольку в нём типична передача в качестве параметров больших массивов[28]. При передаче по значению это потребовало бы больших накладных расходов как по памяти, так и по времени.

Вернёмся к примеру с функций swap. Рассмотрим случай, когда в качестве параметра передаётся один и тот же объект:

swap(&A, &A)

Естественно ожидать, что значение A после вызова должно остаться без изменения. Однако, на самом деле оно станет равным нулю, поскольку присваивание

*y = *y - *x;

при таком вызове эквивалентно

A = A - A;

поскольку *x, *y и A обозначают один и тот же объект, т.е. являются синонимами. Конечно, можно возразить, что это является особым случаем, и ни в одной реальной программе такого не будет, однако, проблема не так проста. Например, если M - массив вещественных, то при вызове

swap(&(M[i]), &(M[j]));

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

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

void swap(float * _x, float * _y)

{

float x = * _x, y = * _y;

x += y; y = x–y; x -= y;

*_x = x; *y = _y;

}

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

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

Рассмотрим, например, функцию, которая вычисляет квадрат либо второго, либо третьего аргумента в зависимости от некоторого условия, значение которого передаётся первым аргументом:

 

 

float if_sqr(int cond, float t, float f)

{

if (cond)

return t * t;

else

return f * f;

}

Теперь эта функция может быть вызвана как

if_sqr(x==0, 0, 1/x)

Ожидается, что при x равном нулю, функция вернёт ноль, однако это не так, поскольку согласно методу передачи параметров по значению сначала вычисляются все три значения фактических параметров, включая 1/x, что приведёт к исключительной ситуации.

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

float if_sqr(int cond, float *t(), float *f())

{

if (cond)

return (*t)() * (*t)();

else

return (*f)() * (*f)();

}

float t_thunk() { return 0; }

float f_thunk() { return 1/x; }

Теперь при вызове

if_sqr(x==0, &t_thunk, &f_thunk)

вычисление последних двух параметров не будет приводить к вызову, а только к выдаче указателей на функции. Функция без параметров, используемая для этих целей, называется thunk [29].

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

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

float power3(x)

{

return x * x * x;

}

и её вызов

power3(power3(power3(sqrt(a)))

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

 

 

float power(float * x())

{

return (*x)() * (*x)() * (*x)();

}

а вызов будет иметь вид

power3(t2_thunk)

при определении дополнительных функций

float sqrt_thunk() { return sqrt(x*x+1); }

float t1_thunk() { return power3(sqrt_thunk); }

float t2_thunk() { return power3(t1_thunk); }

для каждого фактического параметра. Теперь при головном вызове power3 трижды вызовется функция t2_thunk, каждый вызов которой трижды вызовет t1_thunk, каждый вызов которой в свою очередь трижды вызовет sqrt_thunk. В итоге sqrt вызовется 27 раз! Дублирование вычислений, происходящее при передаче параметров по необходимости, может приводить к экспоненциальному снижению эффективности. Это особенно неприятно, если вычисление фактического параметра имеет побочный эффект.

В языке Алгол-60 помимо передачи параметров по значению имеется передача параметров по имени, которая на словах очень проста: тело функции выполняется так, как если бы везде вместо формального параметра был написан текст фактического параметра. То есть (в нотации языка C) если задана функция

float sum_i(float x)

{

float sum = 0;

for (i=0; i<N; i++)

sum += x;

return sum;

}

 то вызов

sum_i(A[i]*B[i])

должен быть эквивалентен вызову функции

float sum_i_AiBi()

{

float sum = 0;

for (i=0; i<N; i++)

sum += A[i]*B[i];

return sum;

}

Такой способ реализации очень похож на макрообработку, но в отличии от неё лучше согласован с синтаксисом и семантикой языка. Копировать тело процедуры для каждого вызова - чрезвычайно расточительно. Реализация передачи параметров по имени в Алгол-60 сходна с рассмотренной выше передачей параметров по необходимости: для каждого фактического параметра траслятор порождает процедуру без параметров, передаваемую в качестве параметра. Разница заключается в том, что при передаче параметров по имени фактический параметр попадает в другой контекст. В приведённом выше примере все использованные в вызове переменные - A, B и i - идентифицируются не в месте вызова, а в месте вхождения формального параметра в теле процедуры, что достаточно сложно выразить в терминах языка C[30].

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

int printf(char *format,...)

вызов которой может иметь вид

printf("%d %c %d = %d",

   x, op, y,

   get_binop(op)(x,y));

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

Для корректной работы с указателями во фрейме используется тип va_list и набор макросов, определённых во включаемом файле stdarg.h. Реализация va_list может существенно меняться в зависимости от конкретной системы пограммирования. Можно предположить, что она помимо прочего содержит указатель на текущий дополнительный параметр. Инициализация переменной типа va_list производится с помощью макроса va_start, которому должен быть передан последний из явно указанных формальных параметр. Зная тип этого параметра и то, как именно размещаются параметры во фрейме, va_start устанавливает указатель на первый дополнительный параметр. Выбор значения текущего параметра и переход к следующему параметру совмещён в макросе va_arg, которому надо указать переменную типа va_list и тип параметра. Наконец, когда обработка всех дополнительных параметров закончена, нужно вызвать макрос va_end на тот случай, если va_start запросил ресурсы, которые теперь требуется освободить.

Рассмотрим это на примере реализации упрощенной версии printf, которую назовём my_printf. Будем считать, что уже реализованы процедуры печати строки и чисел:

void print_string(string s);

void print_int(int i);

void print_float(float v);

Тогда функцию my_printf можно реализовать следующим образом:

#include <stdarg.h>

void my_printf(char * format,...)

{

va_list argptr;

char * f; // указатель на очередной символ в формате.

va_start(argptr, format);

for (f = format; *f; f++)

if (f[0]=='%' && f[1])

{

switch(f[1])

{

   case 's':

     print_string(va_arg(argptr,char *));

     break;

   case 'd':

     print_int(va_arg(argptr,int));

     break;

   case 'f':

     print_float(va_arg(argptr,float));

     break;

   default:

     print_char(f[1]);

}

f++;

}

else     

print_char(f[0]);

va_end(argptr);

}

и вызвать её, например, как

my_printf(“%d: Hello, %s!”, cnt++, UserName);

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

my_printf(“%f + %f = %f”, x, y);

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

my_printf(“%s + %s =?”, UserName, 0.7L);

обработка второго %s приведёт вещественное значение 0.7L к типу указателя и попытается применить к нему функцию print_string опять же с непредсказуемым результатом. И проблема здесь не в том, что мы как-то плохо реализовали нашу функцию - стандартный printf страдает теми же проблемами. На этом фоне жалоба на то, что невозможно описать функцию с переменным числом параметров, если у неё нет хотя бы одного явного параметра, кажется капризом.

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

float average(params float[] A)

{

if (A.length == 0)

return 0.0;

float sum = 0.0;

for (int i =0; i<A.length; i++)

sum += A[i];

return sum/A.length;

}

Эта функция может быть вызвана как

average()

average(1, 2.5, 3.7)

причём транслятор позаботится о том, чтобы выполнить приведение фактических параметров к нужному типу, например, 1 к 1.0. Этого не вполне достаточно, чтобы описать printf, где параметры могут иметь разный тип, но оставшиеся проблемы уже не связаны собственно с передачей параметров.

Рассмотрение вопросов, связанных с передачей параметров, завершим именованными и необязательными фактическими параметрами, которых нет в языке C. Пусть нам нужно описать процедуру, рисующую на экране прямоугольник. Очевидно, что достаточно знать его ширину witdh и высоту height, а также координаты его левого верхнего угла (left, top). Таким образом, процедура может быть определена как

void draw_box(int left, int top, int width, int height);

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

· x_offset и y_offset - смещение окна (экрана) относительно логической плоскости рисования;

· border_width и border_color - ширина границы прямоуголиника и её цвет;

· fill и fill_color - признак того, что надо закрашивать внутренность прямоугольника, и цвет заливки;

· transparency - степень прозрачности при рисовании.

В результате спецификация процедуры становится следующей:

void draw_box(int left,

         int top,

         int width,

         int height,

         int x_offset,

         int y_offset,

         int border_width,

         int border_color,

         int fill,

         int fill_color,

         int transparency);

Теперь типичный вызов этой процедуры будет выглядеть так:

draw_box(100, 200, 50, 100, 0, 0, 1, 0, 1, 16777215, 50);  

Глядя на такой вызов, достаточно трудно сразу понять чему соответствует, например, первый параметр, равный 1. Конечно, современные системы программирования могут показать подсказку, если подвести курсор к нужному место, но это требует дополнительных усилий.

Некоторые языки программирования (например, C#) решают эту проблему, позволяя задать умолчательные значения для некоторых параметров, которые будут использоваться в случае, если они не указаны в вызове. Если бы такая возможность была в языке C, то можно было бы описать процедуру как

void draw_box(int left,

         int top,

         int width,

         int height,

         int x_offset = 0,

         int y_offset = 0,

         int border_width = 1,

         int border_color = 0,

         int fill = 0,

         int fill_color = 0xFFF,

         int transparency = 0);

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

Кардинальным решением является указание имён в вызове. Опять же, если бы такое было возможно в C, то по аналогии с C# можно было бы вызвать draw_box как

draw_box(width:50, height:100, left:100, top:200, transparency:128);

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

 



Понравилась статья? Добавь ее в закладку (CTRL+D) и не забудь поделиться с друзьями:  



double arrow
Сейчас читают про: