Декомпозиция методов и декомпозиция классов

Это часть краткой серии статей о хороших привычках Java-программистов.

В начале…

Когда вы только начинаете изучать Java, чаще всего вы будете писать короткие программы, код которых полностью размещается в методе public static void main класса, который не делает ничего, кроме размещения этого метода main. Например, возможно, вы учитесь выполнять итерации с помощью цикла for, и поэтому читаете или пишете небольшую программу для суммирования всех четных чисел от 0 до 10.

public static void main(String args[]) {
    int sumOfEvens = 0;
    for (int even = 0; even <= 10; even += 2) {
        sumOfEvens += even;
    }
    System.out.println("The sum of evens from 0 to 10 is " + sumOfEvens);
}
Вход в полноэкранный режим Выход из полноэкранного режима

С точки зрения преподавания и обучения имеет смысл держать весь этот код в main: это самое простое место для кода, и, не внося дополнительных сложностей, преподаватель и вы можете сосредоточиться на других изучаемых концепциях — скажем, на том, как работает цикл for, или как использовать локальную переменную (sumOfEvens), или как работает алгоритм (прибавление 2 каждый раз приводит нас к следующему чету). У меня нет проблем с такой методикой преподавания. На самом деле, я думаю, что это хорошая идея — оставить вас кодировать в main, пока вы сосредоточены на других вещах.

Это была небольшая программа, но поскольку студенты начинают именно так, и каждое постепенное увеличение размера кажется таким, ну, постепенным, часто студенты остаются с этой моделью, в конечном счете пишут программы с гораздо большим количеством кода в методе public static void main, чем это целесообразно. Как только студенты узнают, что такое методы и как вызывать их друг из друга, им следует начать декомпозицию логики из main на подметоды: main будет вызывать один или несколько вспомогательных методов, которые сами могут вызывать один или несколько вспомогательных методов, и так далее, пока каждый метод не станет простым и выполняющим единственную задачу. Цель состоит в том, чтобы иметь хорошо именованные методы, где каждый метод имеет единственную ответственность, которая соответствует имени метода.

Преобразование одного метода, в который встроено много кода для выполнения нескольких задач, в несколько методов называется «декомпозицией», потому что она включает в себя разделение (декомпозицию) одного большого метода на несколько меньших методов.

Декомпозиция даже этого небольшого примера

Некоторые студенты понимают суть вопроса, но не уверены, как его применить, особенно для такого маленького примера, как этот. Как бы выглядела декомпозиция? Вот один из вариантов:

    public static void main(String args[]) {
        int sumOfEvens = sumEvensUntil(10);
        outputSumOfEvens(sumOfEvens);
    }

    private static int sumEvensUntil(int until) {
        int sumOfEvens = 0;
        for (int even = 0; even <= 10; even += 2) {
            sumOfEvens += even;
        }
        return sumOfEvens;
    }

    private static void outputSumOfEvens(int sum) {
        System.out.println("The sum of evens from 0 to 10 is " + sum);
    }

Войти в полноэкранный режим Выход из полноэкранного режима

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

    public static void main(String args[]) {
        int stoppingPoint = requestStoppingPoint();
        int sumOfEvens = sumEvensUntil(stoppingPoint);
        outputSumOfEvens(sumOfEvens, stoppingPoint);
    }

    private static int requestStoppingPoint() {
        System.out.println("Type in a non-negative integer for the stopping point.");
        Scanner scanner = new Scanner(System.in);
        // Ignore user input errors, and assume they'll type in a non-negative integer.
        return scanner.nextInt();
    }

    private static int sumEvensUntil(int until) {
        int sumOfEvens = 0;
        for (int even = 0; even <= 10; even += 2) {
            sumOfEvens += even;
        }
        return sumOfEvens;
    }

    private static void outputSumOfEvens(int sum, int stoppingPoint) {
        System.out.println("The sum of evens from 0 to " + stoppingPoint + " is " + sum);
    }
Войти в полноэкранный режим Выход из полноэкранного режима

Возможно, мы также запросим начальную точку:

    public static void main(String args[]) {
        int startingPoint = requestStartingPoint();
        int stoppingPoint = requestStoppingPoint();
        int sumOfEvens = sumEvensUntil(startingPoint, stoppingPoint);
        outputSumOfEvens(sumOfEvens, startingPoint, stoppingPoint);
    }

    private static int requestStartingPoint() {
        System.out.println("Type in a non-negative integer for the starting point.");
        Scanner scanner = new Scanner(System.in);
        // Ignore user input errors, and assume they'll type in a non-negative integer.
        return scanner.nextInt();
    }

    private static int requestStoppingPoint() {
        System.out.println("Type in a non-negative integer for the stopping point.");
        Scanner scanner = new Scanner(System.in);
        // Ignore user input errors, and assume they'll type in a non-negative integer.
        return scanner.nextInt();
    }

    private static int sumEvensUntil(int start, int until) {
        int sumOfEvens = 0;
        for (int even = start; even <= 10; even += 2) {
            sumOfEvens += even;
        }
        return sumOfEvens;
    }

    private static void outputSumOfEvens(int sum, int startingPoint, int stoppingPoint) {
        System.out.println("The sum of evens from " + startingPoint + " to " + stoppingPoint + " is " + sum);
    }
Ввести полноэкранный режим Выйти из полноэкранного режима

Давайте оценим, чем это отличается от того, как выглядела бы типичная программа для начинающих, чтобы сделать то же самое.

    public static void main(String args[]) {
        System.out.println("Type in a non-negative integer for the starting point.");
        Scanner scanner = new Scanner(System.in);
        // Ignore user input errors, and assume they'll type in a non-negative integer.
        int startingPoint = scanner.nextInt();

        System.out.println("Type in a non-negative integer for the stopping point.");
        // Ignore user input errors, and assume they'll type in a non-negative integer.
        int stoppingPoint = scanner.nextInt();

        int sumOfEvens = 0;
        for (int even = startingPoint; even <= stoppingPoint; even += 2) {
            sumOfEvens += even;
        }

        System.out.println("The sum of evens from " + startingPoint + " to " + stoppingPoint + " is " + sum);
    }
Вход в полноэкранный режим Выход из полноэкранного режима

Надеюсь, вы видите, что второй, неразложенный вариант, труднее читать и следовать ему, даже для такой короткой программы. Почему так? Во многом потому, что вся логика программы представлена в виде одной, длинной, линейной последовательности кода. Вы должны мысленно понять, что делает каждый набор строк, присвоить каждому из них некий мысленный ярлык, затем рассмотреть, как каждый из них взаимодействует со следующим мысленно помеченным фрагментом кода, и так далее, по нарастающей. Начинающие Java-программисты признают это, вставляя новые строки, чтобы помочь отделить эти мысленно помеченные биты кода. Есть причина, по которой новые строки расположены именно там, где они расположены: их размещение — это попытка привнести структуру и порядок в то, что иначе кажется неструктурированным и неупорядоченным. На самом деле, вы часто увидите, как начинающие программисты добавляют комментарии, чтобы помочь себе и своим читателям следить за тем, что происходит:

    public static void main(String args[]) {
        // Get the starting point.
        System.out.println("Type in a non-negative integer for the starting point.");
        Scanner scanner = new Scanner(System.in);
        // Ignore user input errors, and assume they'll type in a non-negative integer.
        int startingPoint = scanner.nextInt();

        // Get the ending point.
        System.out.println("Type in a non-negative integer for the stopping point.");
        // Ignore user input errors, and assume they'll type in a non-negative integer.
        int stoppingPoint = scanner.nextInt();

        // Sum the evens
        int sumOfEvens = 0;
        for (int even = startingPoint; even <= stoppingPoint; even += 2) {
            sumOfEvens += even;
        }

        // Print out the result
        System.out.println("The sum of evens from " + startingPoint + " to " + stoppingPoint + " is " + sum);
    }
Войти в полноэкранный режим Выход из полноэкранного режима

Итак, наши новые программисты обычно правильно декомпозируют проблему и ищут способы обозначить декомпозицию проблемы. Правильный способ обозначить декомпозицию проблемы — отразить ее с помощью декомпозиции методов.

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

Декомпозиция класса

Принадлежит ли main этому классу, EvenSummer? Как я уже сказал в «Где должен жить Main», я так не думаю. Я бы перенес этот main в свой собственный класс. Если вы это сделаете, это будет формой декомпозиции класса: разделение обязанностей одного класса на несколько классов, каждый из которых имеет свою цель.

А как насчет подсказок? Должно ли это быть в том же классе, что и класс, который умеет суммировать? А как насчет печати? Было бы лучше не делать этого и оставить EvenSummer только с ответственностью и моделированием того, как суммировать четные. Сделайте его чистым и пусть он отвечает только за одно — управление логикой суммирования четных.

Так где же будет происходить ввод, разбор и вывод? Возможно, в классе Driver, который будет запрашивать ввод и вызывать EvenSummer. Как насчет конечной программы, которая выглядит следующим образом (обратите внимание, что я включаю сюда содержимое нескольких файлов):

// In Main.java
public class Main {
    public static void main(String[] args) {
        Driver.sumUserSuppliedEvens();
    }
}

// In Driver.java
public class Driver {
    public static void sumUserSuppliedEvens() {
        int startingPoint = requestStartingPoint();
        int stoppingPoint = requestStoppingPoint();
        int sumOfEvens = EvensSummer.sumEvensUntil(startingPoint, stoppingPoint);
        outputSumOfEvens(sumOfEvens, startingPoint, stoppingPoint);
    }

    private static int requestStartingPoint() {
        System.out.println("Type in a non-negative integer for the starting point.");
        Scanner scanner = new Scanner(System.in);
        // Ignore user input errors, and assume they'll type in a non-negative integer.
        return scanner.nextInt();
    }

    private static int requestStoppingPoint() {
        System.out.println("Type in a non-negative integer for the stopping point.");
        Scanner scanner = new Scanner(System.in);
        // Ignore user input errors, and assume they'll type in a non-negative integer.
        return scanner.nextInt();
    }

    private static void outputSumOfEvens(int sum, int startingPoint, int stoppingPoint) {
        System.out.println("The sum of evens from " + startingPoint + " to " + stoppingPoint + " is " + sum);
    }
}

// In EvensSummer.java
public class EvensSummer {
    public static int sumEvensUntil(int start, int until) {
        int sumOfEvens = 0;
        for (int even = start; even <= 10; even += 2) {
            sumOfEvens += even;
        }
        return sumOfEvens;
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы разложили нашу логику на классы, каждый из которых имеет свою ответственность, а внутри этих классов мы разложили нашу логику на методы, каждый из которых имеет свою ответственность.

Зачем это делать?

  • Читабельность Намного легче читать декомпозированный код, чем код, который не декомпозирован. Автору и рецензентам легче понять, что делает программа в целом, а также что делает каждая часть и как они сочетаются друг с другом. И нам не нужны комментарии: код самодокументируется!
  • Удобство обслуживания Когда вы разбиваете свои методы и классы подобным образом, гораздо легче вносить обновления в ваш код в дальнейшем. Отчасти это связано с улучшением читабельности — вы можете видеть, где в коде есть швы, а значит, где находятся подходящие места для вставки новой функциональности. Надеюсь, поэтапный способ, с помощью которого я смог добавить подсказку пользователю для двух разных вводов, поможет донести это до читателя. И по мере того, как я добавлял все больше кода, читать мою программу не становилось труднее. Сравните мою версию с декомпозицией методов с версией без декомпозиции методов.
  • Отлаживаемость Большая часть отладки включает в себя визуальный осмотр кода, чтобы понять, что могло пойти не так. Разбиение кода на отдельные, выделенные методы в отдельных, выделенных классах позволяет вам легче сосредоточиться на том, что должен делать данный конкретный метод, и правильно ли он это делает. Ваша ментальная модель становится проще, потому что каждый метод становится проще. Есть также преимущества, связанные с невозможностью влияния одного бита кода на другой: при реализации недекомпозированного длинного кода каждый бит этой реализации может использовать одни и те же переменные и, возможно, случайно перезаписать их или использовать не по назначению. Когда вы декомпозируете свою логику, вы разбиваете ее на более мелкие блоки кода, которые не влияют друг на друга.
  • Возможность повторного использования Одноцелевые методы легче использовать повторно. Вы можете вызывать один и тот же метод из разных мест кода. В данном примере это не так очевидно, потому что мы не используем никакого кода повторно. Многие из ваших ранних программ не имеют большого количества повторно используемого кода. А когда вам понадобится повторно использовать код, вы, вероятно, естественным образом рефакторите свой код, чтобы у вас был метод, который вы можете повторно использовать. Так что эта причина может оказаться для вас немного неудачной, хотя она и верна.
  • Тестируемость Ваши учебники, скорее всего, скажут вам, что легче тестировать отдельные, маленькие, одноцелевые методы и маленькие, специализированные классы. Это очень верно и важно. Но вы также, вероятно, не пишете тестируемый код прямо сейчас. Так что вам придется поверить нам, что это правда, и мы пропагандируем хорошие привычки на будущее, когда вы будете писать тесты.

Как узнать, когда декомпозировать?

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

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

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

Вот отличный совет. У начинающих программистов есть инстинкт: как я уже говорил выше, именно поэтому они вставляют новые строки в свой код, и именно поэтому они добавляют комментарии к блокам кода. Я вижу это, когда они просят меня о помощи, и когда мы вместе смотрим на их код, они говорят мне: «Эта часть получает от пользователя начальное число; затем эта часть получает конечное число; затем эта часть суммирует четные числа; и затем эта часть выводит ответ». Если вы когда-нибудь обнаружите, что делаете что-то из этого — вставляете пустые строки, добавляете комментарии, рассказываете себе или кому-то еще, что делает каждая часть — это очень сильный сигнал, что вам следует декомпозировать вашу логику!

Другие примечания

Обратите внимание, что в моем декомпозированном примере я создаю два экземпляра Scanner. Я мог бы создать один экземпляр и передать его в каждый из методов: например.

    public static void main(String args[]) {
        Scanner scanner = new Scanner(System.in);
        int sumOfEvens = sumEvensUntil(requestStartingPoint(scanner), requestStoppingPoint(scanner));
        outputSumOfEvens(sumOfEvens);
    }
// Updates to requestStartingPoint and requestStoppingPoint ommitted
Войти в полноэкранный режим Выйти из полноэкранного режима

Я также не вызываю close на моих Scanners (и поэтому close никогда не вызывается на этих InputStreams, System.in). Это не очень хорошо, но давайте ограничим содержание того, с чем мы имеем дело. Закрытие ресурсов обычно является более поздней темой на пути начинающего Java-программиста.

Оцените статью
devanswers.ru
Добавить комментарий