7. Введение в ООП (Java. Базовый курс)


Канал в Telegram

7. Введение в ООП (Java. Базовый курс)


Дата публикации 20.02.2021



Основы объектно-ориентированного программирования в Java

Что такое класс?

Класс - это элемент, образующий основу Java, так как он определяет форму и сущность объекта, а Java - это объектно ориентированный язык программирования.

Главная особенность класса в том, что он определяет новый тип данных, которым в дальнейшем можно пользоваться для создания объектов. Проще говоря, класс - это шаблон для создания объекта, а объект - это экземпляр класса.

Для объявления класса в Java предусмотрено ключевое слово class. Общая форма объявления класса выглядит так:

модификатор class имя_класса {

    модификатор тип имя_поля_1; 
    модификатор тип имя_поля_2; 
                ...
    модификатор тип имя_поля_n;

    модификатор конструктор(тип аргумент_1, тип аргумент_2 ...) {
        // тело конструктора
    }

    модификатор тип имя_метода_1(тип аргумент_1, тип аргумент_2 ...) {
        // тело метода
    }
                ...
    модификатор тип имя_метода_n(тип аргумент_1, тип аргумент_2 ...) {
        // тело метода
    }
}

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

Для чего это нужно? В качестве примера напишем простое приложение для банка. У каждого клиента банка есть дебетовая карта и нужно как-то хранить информацию о данной карте, уметь её пополнять и списывать денежные средства. Просто разложить информацию по переменным не получится, так как клиентов много и их число постоянно меняется - на всех переменных не напасёшься :) Лучшем решением будет создание класса, который будет описывать сущность карты. Смотрим пример:

package ru.akutepov;

import java.time.YearMonth;

/**
 * Объявление класса Card
 */
public class Card {

    private final String number; // номер карты
    private final String cardHolder; // имя владельца
    private final YearMonth expiry; // срок действия (год и месяц)
    private final String cvc; // CVC-код

    private double amount; // количество денежных средств на карте

    /**
     * Конструктор класса Card
     */
    public Card(String number, String cardHolder, YearMonth expiry, String cvc) {
        this.number = number;
        this.cardHolder = cardHolder;
        this.expiry = expiry;
        this.cvc = cvc;
        this.amount = 0;
    }

    /**
     * Метод пополнения карты
     * @param amount сумма пополнения
     */
    public void replenish(double amount) {
        if (amount <= 0) {
            return;
        }
        this.amount += amount;
    }

    /**
     * Метод списания денежных средств
     * @param amount сумма списания
     * @return результат списания (true - списание прошло успешно, false - не удалось списать денежные средства)
     */
    public boolean withdraw(double amount) {
        if (this.amount < amount || amount <= 0) {
            return false;
        }
        this.amount -= amount;
        return true;
    }

    /**
     * Метод получения номера карты
     * @return номер карты
     */
    public String getNumber() {
        return number;
    }

    /**
     * Метод получения имени владельца карты
     * @return имя владельца карты
     */
    public String getCardHolder() {
        return cardHolder;
    }

    /**
     * Метод получения срока действия карты
     * @return срок действия (год и месяц)
     */
    public YearMonth getExpiry() {
        return expiry;
    }

    /**
     * Метод получения CVC-кода
     * @return CVC-код
     */
    public String getCvc() {
        return cvc;
    }

    /**
     * Метод получения остатка по карте
     * @return количество денежных средств на карте
     */
    public double getAmount() {
        return amount;
    }
}

Класс получился довольно большой, поэтому я оставил комментарии в коде, чтобы легче было ориентироваться. Теперь разберём данный код более детально, так как тут много тонких моментов, которые необходимо понимать.

В самом начале объявляется класс Card с модификатором доступа public. Это означает что данный класс будет доступен за пределами своего пакета или проще говоря, во всём приложении.

/**
 * Объявление класса Card
 */
public class Card {

В теле класса Card я объявил 5 переменных, 4 из которых с ключевым словом final:

private final String number; // номер карты
private final String cardHolder; // имя владельца
private final YearMonth expiry; // срок действия (год и месяц)
private final String cvc; // CVC-код

Значение переменных, объявленных как final, задаётся 1 раз (в нашем случае в конструкторе) и далее его нельзя будет изменить. Как вы видите, эти переменные хранят карточные данные, которые не подлежат изменению - например вы не можете изменить номер своей карты, можно только выпустить новую карту с новыми данными. Так и в моём примере -  один экземпляр класса Card будет содержать данные одной единственной карты. Если понадобится ещё одна карта, то нужно просто создать ещё один экземпляр класса Card.

А вот количество денег на карте меняется постоянно, соответственно переменная amount объявлена без ключевого слова final:

private double amount; // количество денежных средств на карте

Обратите внимание что для всех переменных я использовал модификатор private. Это значит что доступ к этим переменным есть только внутри класса и они защищены от изменений из вне напрямую: все действия с данными переменными выполняются строго через методы класса. Такой подход соответсвует одному из принципу объектно-ориентированного программирования - инкапсуляции.

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

Далее идёт параметризированный конструктор класса Card:

    /**
     * Конструктор класса Card
     */
    public Card(String number, String cardHolder, YearMonth expiry, String cvc) {
        this.number = number;
        this.cardHolder = cardHolder;
        this.expiry = expiry;
        this.cvc = cvc;
        this.amount = 0;
    }

Конструктор в Java это по сути такой метод, который срабатывает первым в момент инициализации объекта. Конструктор прописывают только тогда, когда нужно проинициализировать какие-либо переменные в классе. В нашем случае мы получаем данные по карте и записываем их в соответсвующие переменные. Имя конструктора обязательно должно совпадать с именем класса, а ключевое слово void или возвращаемый тип не указывается. Если не прописать конструктор в классе, то по умолчанию будет использоваться пустой конструктор. Так как я использую в свойм примере final-переменные, то я обязан их проинициализировать либо при объявлении, либо в конструкторе, соответсвенно использовать пустой конструктор не получится. Но в общем случае такой конструктор мог бы выглядеть так:

    public Card() {
   
    }

Далее в теле класса идут методы пополнения карты и списания средств:

    /**
     * Метод пополнения карты
     * @param amount сумма пополнения
     */
    public void replenish(double amount) {
        if (amount <= 0) {
            return;
        }
        this.amount += amount;
    }

    /**
     * Метод списания денежных средств
     * @param amount сумма списания
     * @return результат списания (true - списание прошло успешно, false - не удалось списать денежные средства)
     */
    public boolean withdraw(double amount) {
        if (this.amount < amount || amount <= 0) {
            return false;
        }
        this.amount -= amount;
        return true;
    }

Если переменные отвечают за хранение данных, то методы отвечают за выполнение определённых действий над этими данными, таким образом вся логика размещается в методах класса. Общий синтаксис объявления методов выглядит так:

модификатор тип_возвращаемого_значения название_метода (тип аргумент1, тип аргумент2 ...) {
    // тело метода
}

Обратите внимание что метод withdraw возвращает значение типа boolean (результат выполнения операции), поэтому после модификатора доступа public я указал тип возвращаемого значения -  boolean. В теле метода я обязан вернуть значение указанного типа с помощью оператора return, например:

return true;

А метод replenish ничего не возвращает, соответсвенно после модификатора доступа идёт ключевое слово void, которое указывает на отсутсвие возвращаемого значения.

Желательно чтобы каждый метод выполнял какое-то определённое действие и не было размывания зоны ответственности метода лишним функционалом. Слишком большие и перегруженные логикой методы - это плохо, их лучше разбивать на несколько отдельных методов.

Методы replenish и withdraw объявлены как public, чтобы их можно было вызывать извне. Изменение значения переменной amount осуществляется строго через эти 2 метода. Если я сделал переменную amount публичной, то в теории в неё можно было бы записать что угодно, например отрицательное значение, или списать деньги, даже если их нет на карте. Методы replenish и withdraw сначала проверяют возможность изменения значения amount, а затем выполняют само изменение, что является хорошей защитой от некорректной работы.

Далее идут 4 метода, которые позволяют узнать данные карты, но не позволяют их изменить. Такие методы называются геттеры (от английскго слова get):

    /**
     * Метод получения номера карты
     * @return номер карты
     */
    public String getNumber() {
        return number;
    }

    /**
     * Метод получения имени владельца карты
     * @return имя владельца карты
     */
    public String getCardHolder() {
        return cardHolder;
    }

    /**
     * Метод получения срока действия карты
     * @return срок действия (год и месяц)
     */
    public YearMonth getExpiry() {
        return expiry;
    }

    /**
     * Метод получения CVC-кода
     * @return CVC-код
     */
    public String getCvc() {
        return cvc;
    }

    /**
     * Метод получения остатка по карте
     * @return количество денежных средств на карте
     */
    public double getAmount() {
        return amount;
    }

Бывают ещё аналогичные методы сеттеры (от английского слова set), которые используются для изменения значений переменных класса. Среда разработки Idea IntelliJ умеет автоматически генерировать геттеры и сеттеры, для этого используйте комбинацию клавиш Alt + Insert и выберите из появившегося меню то что вы хотите сгенерировать.

После того как написан и подробно разобран класс Card, можно создавать переменные типа Card и работать с ними. Я это сделаю в отдельном классе - Main:

package ru.akutepov;

import java.time.YearMonth;

public class Main {

    public static void main(String[] args) {
        // Создание 2-х экземпляров класса Card для хранения данных 2 карт
	    Card card1 = new Card("1111 2222 3333 4444", "Ivan Ivanov", YearMonth.of(2022, 12), "123");
        Card card2 = new Card("4444 3333 2222 1111", "Petr Petrov", YearMonth.of(2021, 10), "098");

        System.out.println("card1 number = " + card1.getNumber());
        System.out.println("card2 number = " + card2.getNumber());

        // Пополнение карт
        card1.replenish(1000);
        card2.replenish(2000);

        System.out.println("card1 amount = " + card1.getAmount());
        System.out.println("card2 amount = " + card2.getAmount());

        // Списание суммы, которая меньше остатка по карте
        boolean result = card2.withdraw(500);
        System.out.println("Результат списания - " + result + " card2 amount = " + card2.getAmount());

        // Попытка списания суммы, которая больше остатка по карте
        result = card2.withdraw(3000);
        System.out.println("Результат списания - " + result + " card2 amount = " + card2.getAmount());

    }
}

В данном примере я создал две переменные типа Card и записал в них данные 2-х разных карт:

// Создание 2-х экземпляров класса Card для хранения данных 2 карт
Card card1 = new Card("1111 2222 3333 4444", "Ivan Ivanov", YearMonth.of(2022, 12), "123");
Card card2 = new Card("4444 3333 2222 1111", "Petr Petrov", YearMonth.of(2021, 10), "098");

Далее вывожу в консоль их номера:

System.out.println("card1 number = " + card1.getNumber());
System.out.println("card2 number = " + card2.getNumber());

Затем пополняю карты и провожу операции списания, выводя результаты в консоль:

        // Пополнение карт
        card1.replenish(1000);
        card2.replenish(2000);

        System.out.println("card1 amount = " + card1.getAmount());
        System.out.println("card2 amount = " + card2.getAmount());

        // Списание суммы, которая меньше остатка по карте
        boolean result = card2.withdraw(500);
        System.out.println("Результат списания - " + result + " card2 amount = " + card2.getAmount());

        // Попытка списания суммы, которая больше остатка по карте
        result = card2.withdraw(3000);
        System.out.println("Результат списания - " + result + " card2 amount = " + card2.getAmount());

А вот и пример работы программы, в котором видно что прошло успешное и неуспешное списание с карты:

Результат работы программы

Однако в данном примере есть один недостаток: я всё равно для каждой карты создаю новую переменную, а в реальных условиях количество карт клиентов заранее не известно и их количество всегда меняется.

Как решить эту проблему? На помощь приходят старые добрые массивы:

package ru.akutepov;

import java.time.YearMonth;

public class Example2 {

    private static final int LEN = 1000;

    public static void main(String[] args) {
        // Создание массива карт
        Card[] cards = new Card[LEN];

        // Создание 2-х экземпляров класса Card для хранения данных 2 карт
        cards[0] = new Card("1111 2222 3333 4444", "Ivan Ivanov", YearMonth.of(2022, 12), "123");
        cards[1] = new Card("4444 3333 2222 1111", "Petr Petrov", YearMonth.of(2021, 10), "098");

        System.out.println("card1 number = " + cards[0].getNumber());
        System.out.println("card2 number = " + cards[1].getNumber());

        // Пополнение карт
        cards[0].replenish(1000);
        cards[1].replenish(2000);

        System.out.println("card1 amount = " + cards[0].getAmount());
        System.out.println("card2 amount = " + cards[1].getAmount());

        // Списание суммы, которая меньше остатка по карте
        boolean result = cards[1].withdraw(500);
        System.out.println("Результат списания - " + result + " card2 amount = " + cards[1].getAmount());

        // Попытка списания суммы, которая больше остатка по карте
        result = cards[1].withdraw(3000);
        System.out.println("Результат списания - " + result + " card2 amount = " + cards[1].getAmount());

    }
}

Я совсем немного изменил программу, а именно добавил массив cards, который хранит в себе объекты класса Card. Массив имеет фиксированную длину в 1000 элементов, значение которой я сохранил в константе LEN. Это решение достаточно примитивное и далеко не самое лучшее - чуть позже я познакомлю вас с коллекциями, а там уже совсем другие возможности! 

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

 

Модификаторы доступа

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

Модификаторы доступа позволяют задавать допустимую область видимости для классов и их членов (поля и методы). Простыми словами - определяют будет ли метод (или поле) доступен для использования объектами других классов.

В Java существует четыре модификатора доступа:

  • public - публичный, общедоступный класс или член класса. Поля и методы, объявленные с модификатором public, видны другим классам из текущего пакета и из внешних пакетов.
  • private - закрытый класс или член класса. Закрытый класс или член класса доступен только из кода в том же классе. По сути это полная противоположность модификатору public.
  • protected - такой класс или член класса доступен из любого места в текущем классе или пакете или в производных классах, даже если они находятся в других пакетах. Чаще всего используется при наследовании классов - эту тему разберём на следующем уроке.
  • модификатор по умолчанию - отсутствие модификатора у поля или метода класса предполагает применение к нему модификатора по умолчанию. Такие поля или методы видны всем классам в текущем пакете.

В своих примерах я наиболее активно использовал модификаторы доступа public и private. Теперь вы знаете какую задачу они выполняют и какова у них область видимости. 

 

Домашнее задание

  1. Доработать программу с урока таким образом, чтобы пользователь мог через консоль вводить новые карточные данные, которые бы затем сохранялись в массив cards.
  2. Напишите класс Contact для телефонного справочника. Подумайте над тем, какие методы и поля добавить в данный класс и какие модификаторы доступа использовать.
  3. Напишите консольную программу "Телефонный справочник" с использованием класса Contact из 2-го задания. Программа должна позволять добавить новый контакт в справочник и выводить в консоль список всех контактов.

< Предыдущий урок