Передача ссылочных типов в функции. Проблема изменения ссылки внутри подпрограммы
При передаче в подпрограмму ссылочной переменной возникает ряд отличий по сравнению со случаем примитивных типов, так как в локальную переменную, с которой идёт работа в подпрограмме, копируется не сам объект, а его адрес. Поэтому глобальная переменная, ссылающаяся на тот же объект, будет получать доступ к тем же самым полям данных, что и локальная. В результате чего изменение полей данных объекта внутри метода приведёт к тому, что мы увидим эти изменения после выхода из метода (причём неважно, будем мы менять поля непосредственно или с помощью вызова каких-либо методов).
Для примера создадим в нашем пакете класс Location. Он будет служить для задания объекта соответствующего типа, который будет передаваться через список параметров в метод m1, вызываемый из нашего приложения.
public class Location {
public int x=0,y=0;
public Location (int x, int y) {
this.x=x;
this.y=y;
}
}
А в классе приложения напишем следующий код:
Location locat1=new Location(10,20);
public static void m1(Location obj){
obj.x++;
obj.y++;
}
Мы задали переменную locat1 типа Location, инициализировав её поля x и y значениями 10 и 20. А в методе m1 происходит увеличение на 1 значения полей x и y объекта, связанного с формальным параметром obj.
Создадим две кнопки с обработчиками событий. Нажатие на первую кнопку будет приводить к выводу информации о значениях полей x и y объекта, связанного с переменной locat1. А нажатие на вторую – к вызову метода m1.
private void jButton1ActionPerformed(java.awt.event.ActionEvent evt) {
System.out.println("locat1.x="+locat1.x);
System.out.println("locat1.y="+locat1.y);
}
private void jButton2ActionPerformed(java.awt.event.ActionEvent evt) {
m1(locat1);
System.out.println("Прошёл вызов m1(locat1)";
}
Легко проверить, что вызов m1(locat1) приводит к увеличению значений полей locat1.x и locat1.y .
При передаче в подпрограмму ссылочной переменной имеется особенность, которая часто приводит к ошибкам – потеря связи с первоначальным объектом при изменении ссылки. Модифицируем наш метод m1:
public static void m1(Location obj){
obj.x++;
obj.y++;
obj=new Location(4,4);
obj.x++;
obj.y++;
}
После первых двух строк, которые приводили к инкременту полей передаваемого объекта, появилось создание нового объекта и перещёлкивание на него локальной переменной obj, а затем две точно такие же строчки, как в начале метода. Какие значения полей x и y объекта, связанного с переменной locat1 покажет нажатие на кнопку 1 после вызова модифицированного варианта метода? Первоначальный и модифицированный вариант метода дадут одинаковые результаты!
Дело в том, что присваивание obj=new Location(4,4); приводит к тому, что переменная obj становится связанной с новым, только что созданным объектом. И изменение полей данных в операторах obj.x++ и obj.y++ происходит уже для этого объекта. А вовсе не для того объекта, ссылку на который передали через список параметров.
Следует обратить внимание на то, какая терминология используется для описания программы. Говорится “ссылочная переменная” и “объект, связанный со ссылочной переменной”. Эти понятия не отождествляются, как часто делают программисты при описании программы. И именно строгая терминология позволяет разобраться в происходящем. Иначе трудно понять, почему оператор obj.x++ в одном месте метода даёт совсем не тот эффект, что в другом месте. Поскольку если бы мы сказали “изменение поля x объекта obj”, было бы невозможно понять, что объекты-то разные! А правильная фраза “изменение поля x объекта, связанного со ссылочной переменной obj” подталкивает к мысли, что эти объекты в разных местах программы могут быть разными.
Способ передачи данных (ячейки памяти) в подпрограмму, позволяющий изменять содержимое внешней ячейки памяти благодаря использованию ссылки на эту ячейку, называется передачей по ссылке. И хотя в Java объект передаётся по ссылке, объектная переменная, в которой хранится адрес объекта, передаётся по значению. Ведь этот адрес копируется в другую ячейку, локальную переменную. А именно переменная является параметром, а не связанный с ней объект. То есть параметры в Java всегда передаются по значению. Передачи параметров по ссылке в языке Java нет.
Рассмотрим теперь нетривиальные ситуации, которые часто возникают при передаче ссылочных переменных в качестве параметров.
Мы уже упоминали о проблемах, возникающие при работе со строками. Рассмотрим подпрограмму, которая, по идее, должна бы возвращать с помощью переменной s3 сумму строк, хранящихся в переменных s1 и s2:
void strAdd1(String s1,s2,s3){
s3=s1+s2;
}
Строки в Java являются объектами, и строковые переменные являются ссылочными. Поэтому можно было бы предполагать возврат изменённого состояния строкового объекта, с которым связана переменная s3. Но всё обстоит совсем не так: при вызове
obj1.strAdd1(t1,t2,t3);
значение строковой переменной t3 не изменится. Дело в том, что в Java строки типа String являются неизменяемыми объектами, и вместо изменения состояния прежнего объекта в результате вычисления выражения s1+s2 создаётся новый объект. Поэтому присваивание s3=s1+s2 приводит к перещёлкиванию ссылки s3 на этот новый объект. А мы уже знаем, что это ведёт к тому, что новый объект оказывается недоступен вне подпрограммы – “внешняя” переменная t3 будет ссылаться на прежний объект-строку. В данном случае, конечно, лучше сделать функцию strAdd1 строковой, и возвращать получившийся строковый объект как результат вычисления этой функции.
Ещё пример: пусть нам необходимо внутри подпрограммы обработать некоторую строку и вернуть изменённое значение. Допустим, в качестве входного параметра передаётся имя, и мы хотим добавить в конец этого имени порядковый номер – примерно так, как это делает среда разработки при создании нового компонента. Следует отметить, что для этих целей имеет смысл создавать подпрограмму, хотя на первый взгляд достаточно выражения name+count. Ведь на следующем этапе мы можем захотеть проверить, является ли входное значение идентификатором (начинающимся с буквы и содержащее только буквы и цифры). Либо проверить, нет ли уже в списке имён такого имени.
Напишем в классе нашего приложения такой код:
String componentName="myComponent";
int count=0;
public void calcName1(String name) {
count++;
name+=count;
System.out.println("Новое значение="+name);
}
Создадим в нашем приложении кнопку, при нажатии на которую срабатывает следующий обработчик события:
private void jButton1ActionPerformed(java.awt.event.ActionEvent evt) {
calcName1(componentName);
System.out.println("componentName="+componentName);
}
Многие начинающие программисты считают, что раз строки являются объектами, то при первом нажатии на кнопку значение componentName станет ”myComponent1”, при втором – ”myComponent2”, и так далее. Но значение myComponent остаётся неизменным, хотя в методе calcName1 новое значение выводится именно таким, как надо. В чём причина такого поведения программы, и каким образом добиться правильного результата?
Если мы меняем в подпрограмме значение полей у объекта, а ссылка на объект не меняется, то изменение значения полей оказывается наблюдаемым с помощью доступа к тому же объекту через внешнюю переменную. А вот присваивание строковой переменной внутри подпрограммы нового значения приводит к созданию нового объекта-строки и перещёлкивания на него ссылки, хранящейся в локальной переменной name. Причём глобальная переменная componentName остаётся связанной с первоначальным объектом-строкой "myComponent".
Как бороться с данной проблемой? Существует несколько вариантов решения.
Во-первых, в данном случае наиболее разумно вместо подпрограммы-процедуры, не возвращающей никакого значения, написать подпрограмму-функцию, возвращающую значение типа String:
public String calcName2(String name) {
count++;
name+=count;
return name;
}
В этом случае не возникает никаких проблем с возвратом значения, и следующий обработчик нажатия на кнопку это демонстрирует:
private void jButton2ActionPerformed(java.awt.event.ActionEvent evt) {
componentName=calcName2(componentName);
System.out.println("componentName="+componentName);
}
К сожалению, если требуется возвращать более одного значения, данный способ решения проблемы не подходит. А ведь часто из подпрограммы требуется возвращать два или более изменённых или вычисленных значения.
Во-вторых, можно воспользоваться глобальной строковой переменной – но это плохой стиль программирования. Даже использование глобальной переменной count в предыдущем примере не очень хорошо – но мы это сделали для того, чтобы не усложнять пример.
В-третьих, возможно создание оболочечного объекта (wrapper), у которого имеется поле строкового типа. Такой объект передаётся по ссылке в подпрограмму, и у него внутри подпрограммы меняется значение строкового поля. При этом, конечно, это поле будет ссылаться на новый объект-строку. Но так как ссылка на оболочечный объект внутри подпрограммы не меняется, связь с новой строкой через оболочечный объект сохранится и снаружи. Такой подход, в отличие от использования подпрограммы-функции строкового типа, позволяет возвращать произвольное количество значений одновременно, причём произвольного типа, а не только строкового. Но у него имеется недостаток – требуется создавать специальные классы для формирования возвращаемых объектов.
В-четвёртых, имеется возможность использовать классы StringBuffer или StringBuilder. Это наиболее адекватный способ при необходимости возврата более чем одного значения, поскольку в этой ситуации является и самым простым, и весьма эффективным по быстродействию и используемым ресурсам. Рассмотрим соответствующий код.
public void calcName3(StringBuffer name) {
count++;
name.append(count);
System.out.println("Новое значение="+name);
}
StringBuffer sbComponentName=new StringBuffer();
{sbComponentName.append("myComponent");}
private void jButton8ActionPerformed(java.awt.event.ActionEvent evt){
calcName3(sbComponentName);
System.out.println("sbComponentName="+sbComponentName);
}
Вместо строкового поля componentName мы теперь используем поле sbComponentName типа StringBuffer. Почему-то разработчики этого класса не догадались сделать в нём конструктор с параметром строкового типа, поэтому приходится использовать блок инициализации, в котором переменной sbComponentName присваивается нетривиальное начальное значение. В остальном код очевиден. Принципиальное отличие от использования переменной типа String – то, что изменение значения строки, хранящейся в переменной StringBuffer, не приводит к созданию нового объекта, связанного с этой переменной.
Вообще говоря, с этой точки зрения для работы со строками переменные типа StringBuffer и StringBuilder подходят гораздо лучше, чем переменные типа String. Но метода toStringBuffer() в классах не предусмотрено. Поэтому при использовании переменных типа StringBuffer обычно приходится пользоваться конструкциями вида sb.append(выражение). В методы append и insert можно передавать выражения произвольных примитивных или объектных типов. Правда, массивы преобразуются в строку весьма своеобразно, так что для их преобразования следует писать собственные подпрограммы. Например, при выполнении фрагмента
int[] a=new int[]{10,11,12};
System.out.println("a="+a);
был получен следующий результат:
a=[I@15fea60
И выводимое значение не зависело ни от значений элементов массива, ни от их числа.
Наличие автоматической упаковки-распаковки также приводит к проблемам. Пусть у нас имеется случай, когда в списке параметров указана объектная переменная:
void m1(Double d){
d++;
}
Несмотря на то, что переменная d объектная, изменение значения d внутри подпрограммы не приведёт к изменению снаружи подпрограммы по той же причине, что и для переменных типа String. При инкременте сначала производится распаковка в тип double, для которого выполняется оператор “++”. После чего выполняется упаковка в новый объект типа Double, с которым становится связана переменная d.
Приведём ещё один аналогичный пример:
public void proc1(Double d1,Double d2,Double d3){
d3=d1+sin(d2);
}
Надежда на то, что в объект, передаваемый через параметр d3, возвратится вычисленное значение d3=d1+sin(d2), является ошибочной, так как при упаковке вычисленного результата создаётся новый объект.
Таким образом, объекты стандартных оболочечных числовых классов не позволяют возвращать изменённое числовое значение из подпрограмм, что во многих случаях вызывает проблемы. Для этих целей приходится писать собственные оболочечные классы. Например:
public class UsableDouble{
Double value=0;
UsableDouble(Double value){
this.value=value;
}
}
Объект UsableDouble d можно передавать в подпрограмму по ссылке и без проблем получать возвращённое изменённое значение. Аналогичного рода оболочные классы легко написать для всех примитивных типов.
Если бы в стандартных оболочечных классах были методы, позволяющие изменить числовое значение, связанное с объектом, без изменения адреса объекта, в такого рода деятельности не было бы необходимости.
Заканчивая разговор о проблемах передачи параметров в подпрограмму, автор хочет выразить надежду, что разработчики Java либо добавят в стандартные оболочечные классы такого рода методы, либо добавят возможность передачи переменных в подпрограммы по ссылке, как, к примеру, это было сделано в Java-образном языке C#.