Методы, их перегрузка и рекурсия

До сих пор мы использовали только существующие методы классов. Например, для вывода информации в консоль мы часто применяли методы:

System.out.println() и System.out.print()

объекта out. Вместе с тем существующих методов языка Java конечно же недостаточно и возникает необходимость создания своих собственных. В связи с этим нужно понимать, в каких случаях целесообразно их создавать. Часто это делается,  чтобы избавить себя много раз писать один и тот же текст в программе. Например, в программах при работе со строками часто требуется сравнивать одну строку с другой, значит, такую операцию лучше определить в виде метода и использовать его по мере необходимости. Вот почему такой метод уже существует и называется equal(). Но на все случаи жизни методы не придумаешь, да это и не нужно, поэтому в программе можно объявить свой, используя следующий синтаксис:

[модификаторы] <тип> <имя метода> ([аргументы]) { <тело метода> }

Модификаторы – это такие определения как

public или static

именно такие модификаторы записаны перед объявлением метода main, существующего в каждой программе:

public static void main(String[] args) {…}

Далее, тип метода – это тот тип данных, который будет он возвращать. Затем, имя метода служит для его вызова в программе (оно придумывается программистом подобно именам переменных). Список аргументов необходим для передачи методу каких-либо данных при его вызове. Тело метода – это набор операторов, которые выполняются при его вызове.

Рассмотрим пример создания метода, который вычисляет периметр прямоугольника. Запишем его внутри класса Main, чтобы мы могли его вызывать в методе main. Наш метод будет выглядеть так:

float perimetr(float a, float b) {
    float res = 2 * (a + b);
    return res;
 
}

Здесь в нем указаны два вещественных аргумента – это ширина и длина прямоугольника, возвращаемый тип также имеет вещественный тип float. Причем, перед каждым аргументом обязательно прописывать его тип. То есть, писать вот так:

float a, b

нельзя. Далее, внутри самого метода создается временная переменная res, которой присваивается результат вычисления периметра. А оператор return показывает, что будет возвращать метод.

Метод main нашей программы запишем так:

public static void main(String[] args) {
    int w = 5;
    float h = 3.4f;
 
    float P1 = perimetr(w, h);
    float P2 = perimetr(5, 4);
 
    System.out.println(P1);
    System.out.println(P2);
}

Смотрите, мы здесь сначала определили две переменные и, затем, вызываем наш метод perimeter сначала с передачей ему переменных в качестве аргументов, а затем, передаем обычные числа. Однако интегрированная среда нам здесь говорит о некой ошибке. Дело в том, что метод main объявлен как статический (модификатор static). Что это такое мы поговорим позже, а сейчас заметим, что из статических методов можно вызывать только другие статические методы. Поэтому перед определением нашего метода пропишем модификатор static.

Теперь ошибок никаких нет и мы можем посмотреть на результат работы нашей программы. Как это все работает? Во-первых, метод вызывается по своему имени, за которым идут круглые скобки и в них при необходимости указываются аргументы. Если аргументы не нужны, то пишем просто круглые скобки. Далее, переданные аргументы копируются в переменные a и b, метод вычисляет периметр и возвращаемое значение присваивается переменным P1 и P2.

А теперь давайте добавим в метод, например, такую строчку после оператора return:

System.out.println(res);

Смотрите, интегрированная среда сообщает о какой-то ошибке. Дело в том, что как только в методе встречается оператор return, его работа заканчивается и все что идет дальше не может быть выполнено. Именно об этом здесь и говорит интегрированная среда. А вот если перенести эту строчку до оператора return, то все будет нормально.

В дополнение к рассмотренным ранее примитивным типам переменных у методов часто используется еще один тип данных – void, означающий, что метод ничего не возвращает. Например, мы хотим написать метод, который бы выводил значения массива на экран. Это можно сделать так:

static void show_ar(byte[] ar) {
    for (byte val: ar)
        System.out.print(val+" ");
}

и, затем, вызвать его в методе main:

byte array[] = {1, 3, 0, -2, 7, 9};
show_ar(array);

Так как здесь использован тип void, то оператор return писать не обязательно.

Теперь смотрите, в методах Java можно указывать произвольное число аргументов определенного типа. Это записывается так:

static void show_ar(int ...ar) {
    for (int val: ar)
        System.out.print(val+" ");
}

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

show_ar(1, 3, 0, -2, 7, 9);

В результате, выведем те же значения в консоль. Вот так можно передавать произвольное число аргументов.

Перегрузка методов

В языке Java существует такая вещь как перегрузка методов. Перегрузка позволяет создавать несколько методов с одинаковыми именами, но разными типами (или числом) аргументов. Например, мы хотим записать метод вычисления модуля числа как для целочисленных значений, так и для вещественных. Это лучше всего сделать через перегрузку таким образом.

static int modul(int x) {
    if (x < 0) x = -x;
    return x;
}
 
static double modul(double x) {
    return (x < 0) ? -x : x;
}

и вызвать их так:

int a1 = modul(-3);
int a2 = (int)modul(-3.5);
double a3 = modul(-3);
double a4 = modul(-3.5);
 
System.out.print(a1+"\n"+a2+"\n"+a3+"\n"+a4);

Смотрите, здесь записаны два метода modul с разными типами аргумента x. Затем, в main идет их вызов. Как вы думаете, как компилятор определяет вызов каких из двух методов подставить в эти строчки? Это делается по типу входных аргументов. То есть, если вот здесь записано -3, значит, будет вызван метод modul с целочисленным аргументом. Если же стоит вещественное число -3.5, то вызовется метод с вещественным типом аргумента. При этом вот эти типы не играют никакой роли в выборе методов. Имеют значение только входные типы данных.

Вы также можете задаться вопросом: зачем вообще делать перегрузку, давайте зададим один метод с вещественным типом и он будет работать и с целочисленными значениями? Все верно, но работать это будет дольше, так как обработка целочисленных данных как вещественных требует большего машинного времени. А один из критериев создания программ звучит: программа должна работать как можно быстрее. Поэтому здесь лучше использовать перегрузку.

Рекурсивные методы

И последнее, что мы рассмотрим на этом занятии – это рекурсивные методы, то есть, методы, которые вызывают сами себя. Да, да, так тоже можно делать. Давайте я сначала приведу пример работы рекурсивного метода, а, затем, мы разберемся как это работает.

static void up_and_down(int n) {
    System.out.println("Уровень вниз " + n);
    if (n < 4) up_and_down(n + 1);
    System.out.println("Уровень вверх " + n);
}

и вызовем его:

up_and_down(1);

Запустим эту программу, и увидим следующее:

Уровень вниз 1
Уровень вниз 2
Уровень вниз 3
Уровень вниз 4
Уровень вверх 4
Уровень вверх 3
Уровень вверх 2
Уровень вверх 1

Почему все так получилось? Давайте разберемся.

Стек вызова функций

Смотрите, когда вызывается какой-либо метод, он помещается в стек вызова методов (чтобы знать, какие методы в каком порядке были вызваны). Сначала в этом стеке находится метод main(), далее, из него вызывается метод up_and_down(1). Что происходит дальше? Этот метод выводит на экран строку «Уровень вниз 1» и проверяет условие 1<4 – да, и идет вызов такого же метода, но уже с аргументом n+1=2. Причем работа первого метода up_and_down(1) и второго up_and_down(2) совершенно независимы друг от друга! Второй метод также выводит строку «Уровень вниз 2» и проверяет условие 2<4. Оно срабатывает и идет вызов следующего метода up_and_down(3). Здесь происходит все то же самое. Наконец, когда мы достигаем up_and_down(4), условие 4<4 не срабатывает и дальнейшего вызова метода up_and_down не происходит и выполняется следующая строчка метода, которая выводит строку «Уровень вверх 4». Что будет дальше? Дальше метод up_and_down(4) завершается и вызов передается методу up_and_down(3) в соответствии со стеком вызова. Но этот метод уже выполнил первые две строчки, поэтому выполняется третья, которая выводит «Уровень вверх 3». Он также завершается, вызов переходит к методу up_and_down(2), он по аналогии выводит третью строку «Уровень вверх 2», передает управление up_and_down(1), он выводит «Уровень вверх 1» и на этом работа рекурсии завершается. Вот так работают рекурсивные методы.

Кстати, обратите внимание, если бы у нас не было вот этого условия, то рекурсия ушла бы вниз, пока стек методов не переполнился бы (возникла бы ошибка stack overflow), поэтому при реализации рекурсивных методов нужно внимательно относиться к глубине их вызова – она не должна быть большой.