Руководство по массивам в Java (ч. 3)

Введение
Ранее в одной из статей мы с вами подробно изучили одномерные массивы. Наверняка в процессе чтения материала вы обратили внимание, что массив не предоставляет никаких методов, облегчающих с ним работу — все приходится реализовывать самостоятельно. Это связано с тем, что массив не имеет как такового класса своей реализации. Вызвать какой-либо собственный метод у него мы не можем (методы Object не в счет) — их просто нет.
Подобные «издевательства» над разработчиками продолжалось до тех пор, пока в JDK 1.2 не появился специальный утилитный класс Arrays, призванный упростить работу с массивами.
Утилитный класс — это класс, имеющий набор статических методов, никак не связанных между собой и не обладающий состоянием. Характерной особенностью таких классов является использование множественного числа в их имени, например, Collections, Objects, а также слов Util и Helper, например, ArrayUtils.
Для использования Arrays в своих классах необходимо прописать импорт:
import java.util.Arrays;
Класс Arrays содержит методы, упрощающие выполнение стереотипных (часто повторяющихся) операций с массивами: отображение, копирование, сортировка, поиск элементов массива и т. д.
В данной статье мы разберем наиболее часто используемые методы при работе с одномерными массивами.
1. Arrays.toString()
Как мы выяснили ранее, вывести элементы массива, просто передав его в метод println(), не выйдет — отобразится значение вида [I@2f490758, получаемое с помощью неявного вызова метода toString() класса Object, что не очень полезно.
Необходимо самостоятельно переопределить toString(), реализовав в нем корректное преобразование в строку, или воспользоваться методом Arrays.toString(), который предназначен для строкового представления одномерных массивов.
Пример:
int[] nums = {2, 4, 6, 8, 10};
System.out.println(Arrays.toString(nums));
Вывод:
[2, 4, 6, 8, 10]
Результатом работы Arrays.toString() является строка из элементов массива, перечисленных через запятую, внутри квадратных скобок.
Если по какой-то причине вас не устраивает такой формат вывода, то либо придется писать свой вариант преобразования, например, через обычный цикл, либо использовать регулярные выражения (1, 2).
Пример:
int[] nums = {2, 4, 6, 8, 10};
String str = Arrays.toString(nums);
System.out.println(str.replaceAll("[\\[\\],]", ""));
Вывод:
2 4 6 8 10
Благодаря применению метода replaceAll() удалось заменить запятые и [ ] на пустые строки.
Еще пример:
String[] letters = {"A", "B", "C"};
System.out.println(Arrays.toString(letters));
Вывод:
[A, B, C]
В качестве следующего примера создадим два класса, поместив экземпляры Example в массив:
public class Main {

    public static void main(String[] args) {
        Example[] arr = {new Example(), new Example(), new Example()};
        System.out.println(Arrays.toString(arr));
    }
}

class Example {
    int num = 3;
}
Вывод:
[Example@3b22cdd0, Example@1e81f4dc, Example@4d591d15]
Arrays.toString() не отобразил значение поля num для элементов массива. Это связано с тем, что для каждого элемента в одной из строк реализации метода Arrays.toString() вызывается метод valueOf().
public static String valueOf(Object obj) {
    return (obj == null) ? "null" : obj.toString();
}
Как видно из примера кода, если элемент массива (в нашем случае Object obj) не равен null, то у него вызывается toString(). Но т. к. у Example не реализован toString(), то используется реализация из класса Object, доставшаяся ему по наследству.
Java не берет на себя ответственность отображать то, что ей не поручали. Реализация toString() по умолчанию ограничивается выводом вида Example@3b22cdd0. Его можно воспринимать как сигнал того, что в пользовательском классе toString() не реализован.
Для решения проблемы в Example необходимо переопределить метод toString():
class Example {
    int num = 3;

    public String toString() {
        return "" + num;
    }
}
Вывод:
[3, 3, 3]
Возможно, кто-то из вас может задаться вопросом: «Зачем вызывать toString() у Arrays, если все равно пришлось писать свою реализацию? Разве смысл использования Arrays.toString() не в том, чтобы не иметь своей реализации?»
Запустим программу, вызвав toString() у массива:
System.out.println(arr.toString());
Вывод:
[LExample;@3d075dc0
Отобразилась информация о типе данных массива и типе хранимой в нем информации. В этом и состоит проблема, что toString() вызывается у массива, а не у его элементов. В то время как Arrays.toString() вызывает toString() у каждого элемента массива. А чтобы ему было, что вызывать (и отображать нужные нам данные), метод toString() необходимо переопределить.
Из приведенных выше примеров можно сделать вывод, что Arrays.toString() может выводить как значения примитивных типов, так и объектов (при условии, что у них реализован toString()), хранящихся в одномерных массивах. Удобство в использовании Arrays.toString() заключается в том, что не нужно использовать цикл для отображения значений массива.
2. Копирование массивов
Частой бывает ситуация, когда требуется из одного массива скопировать в другой все элементы (или часть из них). Рассмотрим на примерах разные способы копирования.
2.1. Копирование в цикле
Копировать элементы из одного массива в другой можно по самым разным причинам: когда в одном массиве необходимо найти все подходящие данные, а затем скопировать их в другой массив; если исходный массив заполнен, то можно создать новый с бо́льшим размером, а затем скопировать в него все элементы из исходного и т. д.
Для начала реализуем стереотипный способ копирования, чтобы увидеть все его недостатки.
Воспользуемся циклом for, в теле которого будем поэлементно копировать значения из исходного массива srcArr в destArr (src — сокращение от source; dest — от destination):
int[] srcArr = {1, 2, 3, 4, 5};
int[] destArr = new int[srcArr.length];

// процесс копирования
for(int i = 0; i < destArr.length; i++) {
    destArr[i] = srcArr[i];
}
В этом коде создаются два массива типа int. Исходный массив инициализируется значениями от 1 до 5. В итоге в for каждый элемент исходного массива копируется во второй массив.
Недостатком данного подхода является то, что приходится вручную писать цикл. Хотелось бы скрыть эти подробности реализации, чтобы избавиться от многословности языка.
2.2. Arrays.copyOf()
Недостаток вышеизложенного способа можно устранить с помощью метода Arrays.copyOf() (появился в Java 1.6), что позволяет сократить код и упростить копирование.
Данный метод применяется тогда, когда необходимо скопировать элементы массива, начиная с нулевого индекса.
Пример:
int[] srcArr = {1, 2, 3, 4, 5};
int[] destArr = Arrays.copyOf(srcArr, srcArr.length);
Метод Arrays.copyOf() принимает 2 параметра: копируемый массив и количество копируемых значений, которое определяет также длину нового массива. Т. к. мы хотим скопировать массив целиком, то указываем в качестве количества длину исходного массива.
Если вам требуется скопировать не весь массив, а только какую-то его часть, то вместо длины следует указать необходимое значение: это может быть как целочисленный литерал, так и значение переменной.
Следует иметь ввиду небольшой нюанс работы метода, связанный с тем, что если количество копируемых элементов указано больше, чем их содержит массив, то copyOf() дополнит итоговый массив значениями по умолчанию.
Пусть имеется заполненный массив чисел, в который необходимо добавить еще одно число. Т. к. размер уже созданного массива менять нельзя, то создадим новый с увеличенным размером:
int[] srcArr = {1, 2, 3, 4, 5};
int[] destArr = Arrays.copyOf(srcArr, srcArr.length + 3);

System.out.println(Arrays.toString(destArr));
destArr[srcArr.length] = 6;
destArr[srcArr.length + 1] = 7;
System.out.println(Arrays.toString(destArr));
Вывод:
[1, 2, 3, 4, 5, 0, 0, 0]
[1, 2, 3, 4, 5, 6, 7, 0]
Рассмотрим чуть более сложный пример:
String[] letters = {"A", "B", "C"};

int len = letters.length;
int newLen = len + (int) (len * 0.75);
System.out.println("Длина нового массива = " + newLen);

String[] destArr = Arrays.copyOf(letters, newLen);

System.out.println(Arrays.toString(destArr));
destArr[len] = "D";
System.out.println(Arrays.toString(destArr));
Вывод:
Длина нового массива = 5
[A, B, C, null, null]
[A, B, C, D, null]
В данном коде создаются два массива типа String. Длина второго массива (newLen) вычисляется по формуле, которая позволяет увеличивать его размер на 75%. Также видно, что copyOf заполняет пустые ячейки null’ом.
2.3. Arrays.copyOfRange()
Если требуется скопировать значения не с начала массива (или не до конца), то необходимо использовать Arrays.copyOfRange() (появился в Java 1.6). Данный метод позволяет копировать определенный диапазон значений массива, начиная с указанного индекса:
int[] srcArr = {1, 2, 3, 4, 5};
int[] destArr = Arrays.copyOfRange(srcArr, 1, 3);
System.out.println(Arrays.toString(destArr));
Вывод:
[2, 3]
Метод copyOfRange() принимает 3 параметра: исходный (копируемый) массив; индекс в исходном массиве, с которого будет начинаться копирование; третий параметр — конечный индекс до которого будут копироваться элементы (значение под этим индексом не включается в копирование).
Еще пример. Необходимо найти максимальное число, затем скопировать в новый массив все значения, которые находятся справа от этого числа, включая само число:
import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        double[] srcArr = {100.1, 120.2, -4.8, 3.5, 70.2};
        double max = srcArr[0];
        int maxNumIndex = 0;

        for (int i = 1; i < srcArr.length; i++) {
            if (srcArr[i] > max) {
                maxNumIndex = i;
                max = srcArr[i];
            }
        }

        double[] destArr = Arrays.copyOfRange(srcArr, maxNumIndex, srcArr.length);
        System.out.println(Arrays.toString(destArr));    
    }
}
Вывод:
[120.2, -4.8, 3.5, 70.2]
Метод copyOfRange(), так же как и copyOf(), если количество копируемых элементов указано больше, чем их содержит массив, дополняет итоговый массив значениями по умолчанию.
2.4. System.arraycopy()
В Java есть еще один способ скопировать массив — с помощью нативного (native) метода arraycopy(), расположенного в классе System.
Его работа имеет принципиальное отличие от копирования элементов массива в цикле: он копирует не поэлементно, а целыми блоками памяти, что влияет положительно на производительность.
Данный класс находится в пакете java.lang, и его не нужно импортировать — это делается автоматически.
Нативными называются методы, реализация которых написана на каком-то другом языке, отличном от Java (например, С/C++), под конкретную ОС, что делает их код платформозависимым, т. е. «родным» для конкретной системы.
Методы, помеченные как native, не могут иметь тела и должны заканчиваться точкой с запятой.
Получается, что сигнатуру метода arraycopy() с ключевым словом native мы можем наблюдать в классе System, а вот реализация, написанная на языках C/C++, находится совсем в другом месте. Она является частью скомпилированной в виде машинного (бинарного) кода библиотеки внутри JVM.
Сигнатура обсуждаемого метода выглядит сложноватой, но к ней можно привыкнуть:
@IntrinsicCandidate
public static native void arraycopy(Object src,  int  srcPos,
                                        Object dest, int destPos,
                                        int length)
Запомнить последовательность аргументов, которые принимает метод, не сложно. Рассуждать можно следующим образом: из исходного массива (src), начиная с начальной позиции (srcPos), нужно скопировать данные в другой массив (dest), в такую-то позицию (destPos), в таком-то количестве (length).
Стоит отметить, что если массив назначения уже содержит данные, то arraycopy() их перезапишет.
Обратите внимание, что оба массива имеют тип Object. Это сделано для универсальности, чтобы данный метод мог работать с массивами любого типа.
Над сигнатурой метода мы также можем наблюдать аннотацию @IntrinsicCandidate (до Java 16 она называлась @HotSpotIntrinsicCandidate), сообщающую о том, что JVM вместо вызова кода, написанного на другом языке (а такие вызовы могут негативно влиять на производительность и делают невозможным выполнять оптимизации кода), может использовать внутреннюю (встроенную) реализацию данного метода, которая есть у JIT (Just-in-Time — технология увеличения производительности программ за счет компиляции часто используемых частей кода).
В режиме интерпретатора (когда JVM выполняет вашу программу байт-код за байт-кодом) виртуальной машиной будет запускаться native-версия arraycopy(), но проработав какое-то время и собрав статистику, при определенных условиях она будет рассматривать методы с подобными аннотациями как кандидаты на замену native-кода внутренним машинным кодом.
Разберем пример работы с arraycopy(). Пусть требуется удалить значение по индексу 0, а затем сдвинуть влево все числа, стоящие от него справа:
import java.util.Arrays;

class Main {
    public static void main(String[] args) {
        int[] arr = {1, 2, 3, 4, 5};
        System.arraycopy(arr, 1, arr, 0, arr.length - 1);
        System.out.println(Arrays.toString(arr));
        arr[arr.length - 1] = 0;
        System.out.println(Arrays.toString(arr));
    }
}
Вывод:
[2, 3, 4, 5, 5]
[2, 3, 4, 5, 0]
После сдвига последнее значение стало дублироваться. По этому нам приходится выполнять вручную обнуление последней ячейки, чтобы она содержала 0.
System.arraycopy(), как и методы копирования из Arrays, делают неглубокую копию (shallow copy) объектов, копируя только ссылки. После копирования новый массив будет указывать на тот же самый набор объектов! Это плохо тем, что изменение элементов в исходном массиве приведет к изменениям в массиве-копии, т. к. они оба хранят ссылки на одни и те же элементы.
Пример:
import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        Player[] srcArr = {new Player("Max"), new Player("Fox"), new Player("LoL")};
        Player[] destArr = new Player[srcArr.length];
        
        System.out.println("До копирования:\n" +
                Arrays.toString(srcArr) + "\n" +
                Arrays.toString(destArr));
        
        System.arraycopy(srcArr, 0, destArr, 0, srcArr.length);
        srcArr[0].setName(null);
        
        System.out.println("\nПосле копирования:\n" +
                Arrays.toString(srcArr) + "\n" +
                Arrays.toString(destArr));
    }
}

class Player {
    
    private String name;
    
    Player(String name) {
        this.name = name;
    }
    
    public void setName(String name) {
        this.name = name;
    }
    
    public String toString() {
        return name;
    }
}
Поскольку результатом является поверхностная копия, изменение имени игрока элемента исходного массива вызвало изменение массива копии.
Если для вас неглубокое копирование не создает каких-либо сложностей при работе программы или нет требований к глубокому копированию (когда при копировании создаются новые объекты), то не рассматривайте копирование только ссылок как проблему. Но знать об этих нюансах все равно необходимо.
2.5. System vs Arrays
Самое время затронуть тему производительности: если методы из класса Arrays делают тоже самое, что и метод arraycopy из класс System, то какой из них использовать? Можно предположить, что arraycopy() должен работать быстрее, т. к. является методом из библиотеки, написанной специально под конкретную ОС, а также у JIT есть его оптимизированная реализация. Попробуем в этом разобраться.
Если заглянуть в код метода copyOf(), то выяснится, что внутри себя он вызывает метод arraycopy():
System.arraycopy(original, 0, copy, 0,
                         Math.min(original.length, newLength));
Поэтому оба метода работают примерно одинаково, что подтверждают результаты тестов производительности.
Отличие между этими методами в том, что результатом работы методов копирования из Arrays является массив, который они сами и создают, в то время как для arraycopy() массив должен быть уже создан. Из-за этого нюанса код с использованием copyOf() получается на пару строк короче. При этом copyOf() контролирует количество копируемых элементов: если их больше, чем в массиве, то новый массив будет дополнен ячейками со значением по умолчанию; если их меньше, то он их просто обрежет.
3. Arrays.fill()
Класс Arrays имеет набор методов, называемых fill(), которые заполняют весь массив одним и тем же значением. Это бывает полезно, когда нужно очистить массив или проинициализировать все его ячейки определенными значениями.
Применение fill() заменяет использование цикла, скрывая все подробности внутри себя. В итоге остается одна лаконичная строка.
Пример:
int[] nums = new int[4];
Arrays.fill(nums, 36);
System.out.println(Arrays.toString(nums));
Вывод:
[36, 36, 36, 36]
В этом примере все ячейки массива заполняются числом 36, а затем отображаются в консоль.
Рассмотренный метод fill() принимает два аргумента: сам массив и заполняемое его ячейки значение, число 36.
Иногда бывает нужно заполнить массив, начиная с определенного адреса. Для этих целей подойдет другая версия метода, которая принимает уже 4 параметра. Два новых из них отвечают за индекс начала и окончания (значение под этим индексом не включается в заполнение) заполнения.
Пример:
String[] arr = new String[10];
Arrays.fill(arr, 3, 7, "Java");
System.out.println(Arrays.toString(arr));
Вывод:
[null, null, null, Java, Java, Java, Java, null, null, null]
В этом примере заполняются только элементы с индексами от 3 до 7. При этом 7 не включается (exclusive), о чем частенько забывают. Т. е. последней ячейкой, которая будет заполнена, является ячейка под индексом 6, т. к. индексация в массиве начинается с 0.
Еще пример. Необходимо обнулить ячейки массива, которые хранят экземпляры класса Example:
Example[] arr = {new Example(), new Example(), new Example(), null, null};
Arrays.fill(arr, 0, 3, null);
System.out.println(Arrays.toString(arr));
Вывод:
[null, null, null, null, null]
4. Arrays.equals()
Элементы массивов можно сравнивать друг с другом в цикле, проверяя значение каждой ячейки на равенство.
Пример:
char[] chars1 = {'M', 'A', 'X'};
char[] chars2 = {'M', 'A', 'N'};

boolean equals = true;


for (int i = 0; i < chars2.length; i++) {
    if (chars1[i] != chars2[i]) {
        equals = false;
        break;
    }
}

System.out.println("Массивы " + (equals ? "равны" : "не равны"));
Вывод:
Массивы не равны
Данный способ выглядит громоздко из-за цикла и логики проверок элементов массива.
В качестве альтернативы можно использовать метод equals(), наследуемый любым массивом от суперкласса Object. Но его реализация по умолчанию ориентирована на сравнение ссылок массивов, а не значений их элементов.
Так выглядит реализация метода equals() в классе Object:
public boolean equals(Object obj) {
    return (this == obj);
}
Пример:
double[] nums = {.5, .7, .3};
double[] copyNums = Arrays.copyOf(nums, nums.length);

System.out.println(Arrays.toString(nums));
System.out.println(Arrays.toString(copyNums));
System.out.println("Массивы " + (copyNums.equals(nums) ? "равны" : "не равны"));
Вывод:
[0.5, 0.7, 0.3]
[0.5, 0.7, 0.3]
Массивы не равны
Хотя массивы и имеют одни и те же значения в одинаковых по индексу ячейках, но программа все равно выдает, что они не равны., т. к. по факту сравниваются ссылки массивов, а не их элементы.
Равенство ссылок нас обычно не устраивает, т. к. каждый новый массив будет иметь свою ссылку.
Два массива считаются равными, если имеют одинаковую длину, а их элементы равны друг другу в том порядке, в котором они находятся в массиве.
Сравнивать ссылки приходится не часто. А вот сравнение элементов массивов — это наиболее востребованная операция. За этой возможностью обратимся к классу Arrays, используя его реализацию equals().
Рассмотрим пример, создав для разнообразия массивы для хранения boolean-значений:
boolean[] states1 = {false, true, false};
boolean[] states2 = {false, true, false};
boolean[] states3 = {false, false, true};

System.out.println("Массивы " + (Arrays.equals(states1, states2) ? "равны" : "не равны"));
System.out.println("Массивы " + (Arrays.equals(states1, states3) ? "равны" : "не равны"));
Вывод:
Массивы равны
Массивы не равны
В этом примере создаются три массива типа boolean. Массив states1 сравнивается с двумя другими. Результат первого сравнения будет true, т. к. states1 и states2 содержат одни и те же значения в одинаковом порядке.
Второе сравнение приведет к false, т. к. states3 хоть и содержит те же элементы, что и в states1, но их порядок отличается.
От сравнения примитивных типов давайте перейдем к объектам.
Пример:
import java.util.Arrays;

public class Main {

    public static void main(String[] args) {
        Example[] arr1 = {new Example(), new Example()};
        Example[] arr2 = {new Example(), new Example()};

        System.out.println(Arrays.equals(arr1, arr2));
    }
}

class Example {
    int num = 3;

    public String toString() {
        return "" + num;
    }
}
Программа выведет false, т. к. мы наступаем на те же самые грабли, что и ранее: внутри Arrays.equals() для сравнения элементов массива вызывается equals() с реализацией по умолчанию (сравнение ссылок). У нас в примере одно поле, но обычно их больше, и необходимо явно указывать, что для автора данного класса является равенством объектов. Для этого нужна своя реализация метода equals(). Я воспользуюсь средой разработки Intellij IDEA и сгенерирую в ней данный метод. В итоге класc Example будет выглядеть следующим образом:
class Example {
    int num = 3;

    public String toString() {
        return "" + num;
    }

    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Example example = (Example) o;
        return num == example.num;
    }
}
Если пропустить в реализации метода разного рода предварительные проверки, то в сухом остатке останется строка с непосредственным сравнением двух значений: поля экземпляра и переданного в метод значения. Результат проверки в виде boolean-значения возвращается методом в место его вызова:
return num == example.num;
Если запустить класс Main с использованием нашей реализации equals(), то предсказуемо два массива окажутся равными.
Автор: Чимаев Максим
Оцените статью, если она вам понравилась!