Владимир Лидовский
В последние годы популярные компьютерные журналы редко предлагает своим читателям материалы по программированию, возможно из-за того, что за прошедшие 15-20 лет ничего нового в этой области не появляется. Однако это не значит, что все здесь абсолютно гладко и достойно только публикации в учебниках. Пример - проблемы работы со ссылками. Действительно, в литературе по программированию, как отечественной, так и зарубежной, о ссылках, как правило, пишут как о чем-то всем известном, само собой разумеющемся... В то время как ответы на очевидные вопросы практически всегда в нем отсутствуют.
Что такое ссылка? Это тип, переменная, параметр подпрограмы или не то, и не другое? Почему в одних языках ссылки противопоставляются указателях, в других мирно с ними сосуществуют, а в третьих даже не называются ссылками? Почему при изучении переводов с английского на русский нельзя не заметить очевидной некорректности перевода этого понятия? Всем, кто знаком с языками программирования не понаслышке, предоставляется возможность пройти небольшой (при анализе примеров) интеллектуальный тренинг ;-) Надеюсь также, что эту статью прочитают те, кто переводит на русский язык литературу по программированию.
Одно из самых неоднозначных понятий языков программирования - это понятие ссылки. Языки С++, Паскаль и Java вводят разные парадигмы для использования этого термина. Другие языки также разбиваются по этому критерию на три группы. Например, ссылочный тип в смысле Паскаля имеется в Аде, а ссылки в смысле Java - в Лиспе, PHP, современном Бэйсике и других. Запутанности добавляет то, что ссылочный тип в смысле Паскаля в С/С++ так никогда не называется. Последнее, а также сам факт наличия "ссылочного типа" в Паскале или Аде связаны с особенностями перевода документации по языкам программирования на русский язык. Почему-то сложилась практика в материалах о Паскале переводить слово pointer (type) как ссылочный тип, а при переводе этого же слова в материалах по С/С++/Java/... использовать слово "указатель". Еще большая путаница возникает из-за того, что в материалах по Паскалю передача параметров по ссылке (by reference) переводится именно как передача по ссылке, что приводит к отсутствующей в исходных англоязычных материалах коллизии между понятием типа указателей и такой передаче параметров.
Почему с переводом слова "ссылка" возникает столько проблем? Если отвлечься от относящегося к пенитенциарной системе смысла, то существительное "ссылка", как производное от "ссылаться" весьма точно соответствует английскому существительному "a reference", связанному с глаголом "to refer". Однако в отличие от английского языка в русском это слово не полностью формально определено в словарях. В ссылочной теории смысла (The referential theory of meaning) рассматривается регулярный процесс появления идей одних сущностей при наблюдении за другими сущностями. Например, при виде дыма естественно подумать об огне. То, что наблюдается, в разных контекстах называют знаком, символом, ссылкой, означающим, денотатом. То, о чем можно подумать, - смыслом, значением, означаемым, десигнатом. В приведенном примере дым является ссылкой на огонь, так же, как подпись является ссылкой на подписавшегося.
Проблема в том, что в русском, в отличие от английского, слово "ссылка" почти всегда выпадает из первого списка и рассматривается только в частном значении книжной ссылки (см. словарь С. Ю. Ожегова и Н. В. Шведовой). Таким образом, ссылка в отличие от указателя уже обладает ассоциированным значением, а указатель можно использовать для доступа к этому значению, применяя соответствующие средства.
Итак, если отбросить проблемы с переводом, то остается только два способа работать с понятием ссылки: способ, принятый в С++, и способ, используемый практически во всех других языках, где есть понятие ссылки. Чтобы четко определить понятие ссылки, нужно начать с определения понятия "переменная", которое является базовым для всех языков программирования, хотя в некоторых из них, например, в PostScript, переменные называются иначе, а работа с переменными в Прологе или в PostScript мало похожа на работу с ними в большинстве других языков. У переменной должен быть адрес в памяти - номер байта в последовательности занумерованных с нуля всех байтов памяти компьютера. Переменная также должна иметь размер - некоторое количество последовательных байтов, начиная с ее адреса. Этот размер определяется типом переменной. В некоторых языках (С, С++, Паскаль, Java,...) тип переменной устанавливается заранее и его впоследствии нельзя менять. В языке Бэйсик тип можно также жестко установить, но есть еще возможность при использовании типа Variant предоствить переменной иметь значения любого типа. В "бестиповых" языках, например, Лиспе, или в языках сценариев PHP, JavaScript, AWK, TCL, BASH и др. тип переменной может почти свободно меняться при исполнении программы.
Как правило (исключение составляют лишь некоторые языки сценариев PHP, BASH, но не JavaScript, TCL, AWK или VBScript), при обращении к имени переменной происходит обращение по связанному с ней адресу. Некоторые языки предоставляют возможность явно создавать переменные в процессе исполнения программы, что называется динамической работой с памятью. Созданные таким образом переменные также называются динамическими, для их использования необходимы ссылки или указатели. Не все языки программирования предоставляют средства для явного уничтожения таких переменных. Во время своего существования динамические переменные практически ничем не отличаются от других переменных (могут быть небольшие различия в синтаксисе). Рассмотрим пример на С++.
int *const &a1 = new int[10], a2[10], *a3 = new int [10];
Дальнейшая работа с a1, a2 и a3 будет протекать практически идентично. Память для a1 и a3 выделена динамически, но только для a3 ее размер можно будет потом менять (кстати, a1 - это ссылка типа указатель). В JavaScript (как и в Java) все массивы - динамические, например,
var a3 = new Array(10);
создаст массив, размер которого затем можно будет изменить. В языках PHP, AWK, Perl, C++ (со стандартной библиотекой) и других, где есть ассоциативные массивы, все такие массивы неявно являются динамическими в силу своей природы. В некоторых языках (PostScript, Python) ассоциативные массивы называют словарями.
Очевидно, что при смене типа переменной может измениться ее размер. В случае уменьшения размера в памяти образуется бесполезный "зазор", что неявно требует освобождения всей занимаемой переменной памяти и выделения новой, а случай увеличения размера приводит уже к явной необходимости смены адреса переменной.
Однако в языках программирования, где тип переменных фиксируется, обычные переменные не могут менять свой адрес. Для гибкой работы с адресами данных могут использоваться как переменные-указатели, так и переменные-ссылки. Кроме того, необходимость настраиваться на разные адреса в памяти возникает при передаче параметров по ссылке.
Указатель - это переменная, предназначенная для указания на переменные и другие объекты в памяти компьютера (например, подпрограммы). В каком-то смысле они похожи на дорожные указатели - они указывают на место в памяти и часто содержат информацию о типе или размере этого места. Появление типа указатель было весьма скандальным. Впервые такой тип был независимо введен в языки Си и Паскаль. Известнейший теоретик программирования Хоар встретил это фразой: "Их введение в высокоуровневые языки было шагом назад, от которого мы никогда не оправимся". Написано множество работ, в которых обсуждаются недостатки указателей. Главным образом критикуется возможность появление висячих указателей, указывающих по неверным адресам, например, на уже удаленный объект. Например, в следующем фрагменте программы на Паскале образуется висячий указатель p.
var
p: ^integer;
begin
new(p);
p^ := 11;
dispose(p);
Целой переменой p^ можно после этого присвоить некоторое число, что является очевидно семантически некорректным действием. Кроме того, использование указателей может приводить к потере связи с объектом в памяти, что превращает такой объект в "мусор", который нельзя даже явно удалить (его может удалить только специальный процесс уборки мусора, которого в Паскале или C/C++ нет). Например, в коде на С++
int *p = new int;
p = new int;
созданная первой динамическая переменная превращается именно в такой мусор. Борьба с этими недостатками привела к появлению неэффективного и несколько жутковатого по названию метода "надгробий", а также к более благозвучному методу "ключей и замков". Если вернуться к аналогии с дорожными указателями, то все сводится к желанию сделать невозможными неправильные указатели на дорогах.
Активное неприятие указателей дало свои результаты: в большинстве языков, созданных на основе Си (Java, C#, PHP, JavaScript, ...), указателей нет. Хотя в PHP или BASH нет явных указателей, но синтаксически работа с переменными организуется, как если бы все переменные были символическими указателями. Разница между обычными и такими указателями, в точности такая же, как между индексами обычных и ассоциативных массивов. Например, исполнение фрагмента кода PHP
$c = "z";
$b = "c";
$a = "b";
echo $$$a;
приведет к печати символа z. Знак доллара означает "обратиться к указываемому значению" - именно так и работают с указателями! Кстати, здесь опять сталкиваемся с "трудностями перевода" - в отечественной литературе эти фактические символические указатели обычно переводят как "символические ссылки", хотя в исходных материалах случай повторного использования знака доллар называется переменная от переменной (variable variable).
Этот некорректный, запутывающий суть перевод вызван другой трудностью в переводе: в литературе по файловым системам есть термины "символическая ссылка" и "жесткая ссылка". Похоже, что из-за того, что связанные с этими терминами понятия отдаленно похожи на используемые в PHP ссылки (просто references без какой-либо "жесткости") и обычные переменные, эти термины переводятся как "жесткие" и "символические ссылки". В ангоязычной литературе по файловым системам используется другое слово - link (связь), в смысле близком, но не идентичном понятию "ссылка" в языках программирования. Действительно, при работе с файловыми символическими ссылками (ярлыками) не нужно отслеживать всю возможную цепочку связей, как в примере выше, - значение у всех ссылок в такой цепочке одно.
Сравним приведенный код PHP с аналогичным кодом для Си.
char *c = "z",
**b = &c,
***a = &b;
printf("%c", ***a);
Разница только в том, что в Си нужно каждый раз точно указывать тип переменной. Это был искусственный пример - в общем случае для описания работы с символьным указателем нужен ассоциативный массив. Рассмотрим точный аналог приведенного кода PHP с использованием такого массива на языке AWK (именно там впервые были введены ассоциативные массивы) - нужно лишь везде знак $ заменить на обращение к массиву s.
s["c"] = "z"
s["b"] = "c"
s["a"] = "b"
print s[s[s["a"]]]
Подобные длинные обращения (в случае, если значения образуют цикл, то эти обращения могут быть неограниченной длины) очень естественно реализованы в Лиспе, где нет ни явных указателей или ассоциативных массивов, ни специального понятия ссылка. В частности, рассматриваемый пример в точном переводе на Лисп выглядит так.
(set 'c 'z)
(set 'b 'c)
(set 'a 'b)
(print (eval (eval (eval 'a))))
Фактически обычные указатели - это индексы к массиву байт всей памяти компьютера. Можно еще вспомнить, что в Си работа с массивами естественно сводится к алгебре указателей: в следующем примере строки два и три эквивалентны.
int a[4];
a[1] = 8;
*(a+1) = 8;
В языке С++ есть еще указатели на компоненты структур, которые являются индексами к байтам памяти, выделяемым для конкретных реализаций таких структур. Например,
struct T {
int i;
} t;
int T::*i_off = &T::i;
void f() {t.*i_off = 7;}
Здесь i_off - это относительное смещение любого целого числа относительно начала любого объекта типа T, например, смещение поля i в классе t. То, что переменная является указателем, всегда определяется ее типом. Например, для типа "целое число" может быть тип "указатель на целое число". Однако все указатели - это адреса и поэтому легко (язык Ада здесь исключение) допускают преобразования друг в друга и в целые числа. Естественно работать с бестиповыми указателями - им при конкретном обращении можно назначить любой требуемый тип.
Указатели размещаются в памяти, как правило, так же, как и переменные целого типа. По сути указатели позволяют явно работать с переменными и другими объектами с адресом через их адреса.
В Паскале или Аде указатели можно только сравнивать на равенство или неравенство. В С (и С++) к ним можно применять любые сравнения, а также операции + и -, что дает большую гибкость, особенно при работе с массивами или строками (строки в С, как и в Паскале, - это массивы символов). Рассмотрим пример
char s[] = "abcde", *p = s + 1;
int i = p - s;
Здесь p устанавливается значением строки "bcde", а i - значением числа 1: s[3] и p[2] - это один и тот же символ 'd'. Разница между s и p в том, что s имеет фиксированную привязку к памяти, хранящей строку "abcde", а p может указывать на любой символ как отдельный, так и в составе любой строки.
В силу очевидного по смыслу соответствия указателей и индексов массива, проблемы висячих указателей и выход индекса неассоциативного массива за диапазон допустимых значений являются родственными. Хотя технически вторая проблема, из-за отсутствия сплошного диапазона допустимых значений для указателей, определяется проще.
А теперь о параметрах и возвращаемых значениях. При передаче параметров используют два основных способа: по значению или по ссылке (это четкое разделение впервые появилось в Паскале). Формальным параметрам-значениям не нужно иметь какой-либо привязки к памяти, занимаемой соответствующими им фактическими параметрами, - значения фактических параметров просто копируются в формальные. В случае передачи параметров по ссылке такая привязка необходима - формальные параметры должны получить адреса соответствующих фактических параметров. В этом случае происходит отождествление формальных и фактических параметров - формальные параметры становятся синонимами фактических. Рассмотрим пример на Паскале.
procedure t(var p: integer);
begin
p := 25
end;
var
v: integer;
begin
v := 52;
t(v);
writeln(v)
end.
Его выполнение приведет к печати числа 25, т. к. внутри процедуры t происходило отождествление формального параметра p с фактическим параметром v.
Результаты функций также могут передаваться по значению или по ссылке. В первом случае создается временное значение-результат, которое после его использования в выражении автоматически уничтожается. Во втором случае результат записывается в определенное место в памяти. Если функция возвращает результат-ссылку, то она допустима, в частности, в левой части оператора присваивания, например, код С++
int& f(int &p) {return p;};
main () {
int v;
f(v) = 7;
установит значение переменной v в 7.
Параметры и результаты функций - это неявные локальные переменные, в частности, параметры, передаваемые по ссылке, и результаты функций, возвращаемые по ссылке, - это переменные-ссылки, локальные для подпрограммы, где они используются. В языке Паскаль Вирта есть такие ссылки-параметры, но явных ссылок нет.
К сожалению, в некоторых отечественных книгах по Паскалю параметры-ссылки называются параметрами-переменными, что, конечно, создает путаницу, т. к. любые формальные параметры - это обычные переменные, открытые для установки своих значений при вызове функции. В языке Ада есть даже специальные средства для регулирования степени такой открытости.
С другой стороны, если называть параметры-ссылки параметрами-переменными по причине того, что им при вызове подпрограммы должны сопоставляться переменные, то это формально вполне корректно, но ограничивает общее понятие передачи по ссылке рамками языка Паскаль. В С++, например, по ссылке можно передавать и значения выражений, например, исполнение
int f(const int &p) {return p;};
main () {
cout << f(1+1) << endl;
} приведет к выводу 2.
Используя указатели, можно неявно производить передачу параметров по ссылке через явное использование передачи параметров по значению - так это принято в языке Си, где все параметры могут передаваться только по значению. Например, исполнение
int f(int *p) {return (*p)++;};
main () {
int v = 7;
printf("%dn", f(&v));
}
приведет к печати 7 и установки v в 8. Аналогичный пример на Си++ с явным использованием передачи параметра по ссылке выглядит чуть более элегантным.
int f(int &p) {return p++;};
main () {
int v = 7;
printf("%dn", f(v));
}
Кроме ссылок, формальных параметров подпрограмм могут быть еще ссылки, работа с которыми практически ничем не отличается от работы с обычными переменными. Языки Си++, Java, Лисп и некоторые другие предлагают возможности для такой работы. Сначала надо четко определить это понятие.
Ссылка - это переменная с устанавливаемым адресом, в отличие от обычной переменной, адрес для которой определяется транслятором произвольно. В отличие от указателей при обращении к значению переменной-ссылки не используют специальных символов.
В расширение Паскаля фирмы Borland можно ввести ссылку при помощи конструкции со служебным словом absolute. Например,
i: integer;
j: integer absolute i;
вводит ссылку j. Аналогичный пример на С++ выглядит так:
int i;
int &j = i;
В Лиспе ссылками неявно организуются основные структуры данных языка - списки и бинарные деревья. Однако, здесь нет явной операции с возможностями функции new и все работа с динамической памятью также реализуется неявно. Приведенный пример переписывается на Лиспе в следующей форме (i может быть любого типа).
(setf j i)
В Java и JavaScript ссылки - это переменные-массивы и переменные-объекты. В Бэйсике - только объекты. В этих языках нет специального типа "ссылка". В языках Java и C# со строгой проверкой типов ссылки призваны полностью заменить указатели.
Присваивание в этих языках во многих случаях передает не значение, а ссылку. Передача параметра по ссылке - это неявное ссылочное присваивание. В современном Бэйсике существует как присваивание значений со словом LET, обычно игнорируемым, так и ссылочное присваивание SET. В PHP можно заменить присваивание значений ссылочным присваиванием, записывая =&, вместо =. Например, в
$i = 7;
$j = $i;
$k =& $i;
присваивание $j=$i просто установит $j в 7, а присваивание $k=&$i еще и установит адрес значения $k таким же как и у $i. В Лиспе вместо присваивания значения, SET, можно использовать присваивание ссылки, SETF. Кроме того, есть еще функции RPLACA, RPLACD, NCONC для частных случаев ссылочного присваивания.
Такая работа с переменными постоянно приводит к тому, что старые значения переменных становятся невозможными для дальнейшего использования, т. е. превращаются в "мусор", что приводит к необходимости организации автоматической его уборки. Такая уборка, которая запускается специальными стандартными средствами является необходимой частью любого транслятора с Лиспа или Java. В этих языках нет необходимости в явном удалении динамических объектов из памяти и не может возникнуть проблемы висячих указателей. Рассмотрим несколько примеров.
(setq v '(a b c))
(setq v 4)
В этом фрагменте программы на Лиспе переменной v сначала был присвоен список (a b c), а затем число 4. Список после второго присваивания превращается в мусор.
int [] a1 = new int[100];
int [] a2;
a2 = new int[200];
a1 = a2;
Здесь в коде на Java переменная a1 становится последовательно массивом из 100 и 200 целых чисел. После ссылочного присваивания a1=a2 память, выделенная для массива из 100 чисел также превращается в мусор.
Ссылки в Паскале, кроме параметров подпрограмм, являются несколько искусственной возможностью и практически не используются. Ссылки в Си++ являются на взгляд автора чрезвычайно неудачно определенными - ссылки здесь это особый тип, когда по сути они являются спецификатором класса памяти, таким как static или extern. Действительно переменные типов int и int& невозможно различать в практически любых контекстах - ссылки лишь имеют заранее известный адрес. В Си++, в отличие от Java, адрес ссылки фиксируется при ее создании и не может потом меняться, например,
int &a = *new int;
определяет целую ссылку, изменить адрес которой впоследствии уже невозможно. Динамическую память выделенную для ссылки в таких условиях освобождать бессмысленно, хотя и возможно явным обращением к delete (это приведет к возникновению того, что можно назвать висячей ссылкой, объекта не менее потенциально опасного, чем висячий указатель). Полная поддержка ссылок в Си++ могла бы быть реализована отказом от искусственного ссылочного типа и введением дополнительной операции, ссылочного присваивания, готовый синтаксис которой уже есть в языке (&i=j), но это потребовало бы введение поддержки механизма автоматической уборки мусора.
А теперь о деле "Ссылки против указателей"... Здесь речь пойдет только о ссылках в смысле Java, т. к. ссылки в смысле Си++ не противопоставляются указателям, а дополняют их. Лисп неявно использует ссылки, но в отличие от Java они не появились в результате выбора между ними и указателями.
Ссылки требуют меньше синтаксических правил и значков для работы с ними. Работа с переменными-ссылками часто ничем синтаксически не отличается от работы с переменными-нессылками.
С другой стороны, ссылки требуют понимания того, как с ними работать, т. е. понимания того, что ссылки - это замаскированные указатели. В отличной книге по Java (К. С. Хорстманн, Г. Корнелл Библиотека профессионала. Java 2. Том 1. Основы. - М.: Издательский дом "Вильямс", 2004. - 848 с.) ссылки именно так и объясняются. Поэтому утверждение, что ссылки проще, очень похоже на спорное утверждение о том, что рекурсивные подпрограммы проще своих итерационных аналогов. Действительно, рекурсивные подпрограммы, как правило, выглядят красивее и менее технично, но это ценой скрытия механизма рекурсии, который необходимо четко представлять.
Получается, что синтаксическое упрощение достигается семантическим усложнением, дополнительным требованиям по пониманию синтаксиса конструкций. В частности, требуется понимать разницу между часто внешне идентичными присваиваниями по значению и присваиваниями по ссылке. Рассмотрим две команды языка Лисп, производящие на первый взгляд совершенно одинаковые действия, дающие в результате объединяющий список (a b c d):
(append '(a b) '(c d))
(nconc '(a b) '(c d))
В первом случае из двух списков-аргументов получается третий список, их объединение, после чего списки-аргументы превращаются в мусор. Во втором случае два списка аргумента объединяются установкой последнего указателя в первом списке на начало второго списка - мусора тут не возникает, но могут возникнуть довольно большие сложности, если списки-аргументы задаются переменными. Во втором случае было неявное ссылочное присваивание. Рассмотрим еще два явных и синтаксически идентичных присваивания на Java:
int [] a = new int[10], b = new int [5];
int i, j = 7;
i = j;
a = b;
Присваивание a=b ссылочное, а i=j по значению. Эти проблемы Java легко устраняются введением дополнительной операции ссылочного присваивания. Возможно, авторы языка отказались от этого из-за того, что такой подход усложнил бы язык и лишил бы ссылки кажущейся простоты и элегантности. Есть еще и чисто практический аспект: издержки по автоматической уборке мусора для простых скалярных типов (как в Лиспе) могут в несколько раз замедлить присваивания с данными таких типов, эти же издержки при использовании объектых типов (а массивы во многих языках рассматриваются как объекты) незначительны по сравнению с издержками при альтернативном присваивании по значению.
Автоматическая уборка мусора не дает программисту возможности, "открыв скобку", поставить затем парную "закрывающую скобку": после создания объекта его нельзя явно уничтожить. В частности, использование ссылок вместо указателей при работе со структурами данных (деревьями, множествами, сетями и проч.) приводит невозможности явной операции уничтожить при уничтожении элемента структуры. Подобные возможности всегда считались отличительной особенностью средств для быстрой разработки небольших программ и критиковались в составе средств для разработки больших проектов.
Процесс автоматической уборки мусора требует времени и приводит к некоторому замедлению работы основного процесса. Такая уборка мусора более естественна для преимущественно интерпретирующих систем, таких как Лисп или Java (издержки по уборке мусора в Лиспе более значительны, чем в Java).
Фактически ссылки отличаются от указателей только неявностью прямой работы с адресами памяти компьютера. И как следует из существующей практики появления новых языков программирования для быстрого создания программ, ссылки требуют меньшей подготовленности программиста для работы с ними.
Так что же лучше, ссылки или указатели? Работа со ссылками несколько менее громоздка, а указатели - это более точный и чувствительный инструмент, требующий достаточно высокой квалификации. Окончательное решение как всегда остается за теми, кто реально использует те или иные системы программирования, и, конечно, за читателями!