Java Курс

Java ООП: классы, объекты, наследование

Самоучитель Python 3, собранный из материалов данного сайта.Предназначен в основном для тех, кто хочет изучить язык программирования Python с нуля.

Введение: Что такое ООП и зачем оно нужно?

Привет, юные программисты! Сегодня мы погрузимся в одну из самых важных тем в Java — объектно-ориентированное программирование (ООП). Если вы уже написали несколько простых программ, то заметили, что код может быстро стать запутанным, особенно когда программа становится большой. ООП — это как волшебный органайзер, который помогает навести порядок в коде, делая его понятным, удобным для изменений и повторного использования.

Представьте, что вы строите город из LEGO. Если у вас есть только кубики одного типа, построить сложный город будет трудно. Но если у вас есть разные детали: колёса, окна, двери, человечки — вы можете собирать машины, дома и целые улицы. В ООП мы тоже создаём «детали» — объекты — и учимся их собирать вместе.

На этом уроке мы разберём четыре главных принципа ООП: инкапсуляцию, наследование, полиморфизм и абстракцию. Не пугайтесь этих слов — к концу урока вы будете использовать их как настоящий профи!

Что такое объект и класс?

Прежде чем говорить об ООП, давайте вспомним основы. В Java всё крутится вокруг классов и объектов.

Класс — это как чертёж или шаблон. Например, чертёж дома: на нём указано, сколько будет окон, дверей, какой высоты стены. Сам дом по этому чертежу ещё не построен — это просто план.

Объект — это конкретный дом, построенный по чертежу. У него есть реальные окна, двери, цвет стен. Можно построить много одинаковых домов по одному чертежу, и каждый будет отдельным объектом.

В Java класс описывает, какие свойства (поля) и действия (методы) будут у объекта. Давайте напишем простой класс Собака:

// Класс — это шаблон для создания объектов
public class Собака {
    // Поля (свойства) — это данные, которые есть у каждой собаки
    String имя;
    int возраст;
    String порода;

    // Метод (действие) — собака может лаять
    void лаять() {
        System.out.println("Гав-гав! Меня зовут " + имя);
    }

    // Ещё один метод — собака может бежать
    void бежать() {
        System.out.println(имя + " бежит быстро-быстро!");
    }
}

Теперь создадим объект этого класса в главной программе:

public class ГлавнаяПрограмма {
    public static void main(String[] args) {
        // Создаём объект класса Собака
        Собака мояСобака = new Собака();
        
        // Заполняем поля объекта
        мояСобака.имя = "Бобик";
        мояСобака.возраст = 3;
        мояСобака.порода = "Дворняжка";
        
        // Вызываем методы объекта
        мояСобака.лаять();  // Выведет: Гав-гав! Меня зовут Бобик
        мояСобака.бежать(); // Выведет: Бобик бежит быстро-быстро!
    }
}

Видите? Мы создали один объект, но можем создать и второй, третий — каждый со своим именем и возрастом. Это и есть основа ООП: мы работаем не с абстрактными данными, а с объектами, которые имеют состояние (поля) и поведение (методы).

Принцип №1: Инкапсуляция — защита данных

Представьте, что у вас есть сейф с деньгами. Вы не хотите, чтобы любой прохожий мог открыть его и взять деньги. Вы ставите замок и даёте ключ только тем, кому доверяете. В Java инкапсуляция работает похоже: она скрывает внутренние данные объекта и разрешает доступ к ним только через специальные методы.

Зачем это нужно? Допустим, у нас есть класс БанковскийСчет. Если поле баланс будет открытым, любой может написать счет.баланс = -1000 и уйти в минус. Чтобы этого избежать, мы делаем поля закрытыми (private) и создаём методы для их изменения с проверками.

В Java для этого используются модификаторы доступа:

  • public — доступно всем
  • private — доступно только внутри класса
  • protected — доступно внутри класса и наследникам (о них позже)

Давайте перепишем класс Собака с использованием инкапсуляции:

public class Собака {
    // Закрытые поля — никто не может изменить их напрямую
    private String имя;
    private int возраст;
    private String порода;

    // Конструктор — специальный метод для создания объекта
    public Собака(String имя, int возраст, String порода) {
        this.имя = имя;  // this.имя — это поле объекта, а имя — параметр
        this.возраст = возраст;
        this.порода = порода;
    }

    // Геттеры (методы для чтения полей)
    public String getИмя() {
        return имя;
    }

    public int getВозраст() {
        return возраст;
    }

    // Сеттеры (методы для изменения полей с проверкой)
    public void setВозраст(int возраст) {
        if (возраст > 0 && возраст < 30) {  // Проверяем, что возраст разумный
            this.возраст = возраст;
        } else {
            System.out.println("Ошибка: возраст должен быть от 1 до 30 лет");
        }
    }

    public void лаять() {
        System.out.println("Гав-гав! Я " + имя);
    }
}

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

public class ГлавнаяПрограмма {
    public static void main(String[] args) {
        // Создаём объект через конструктор
        Собака собака = new Собака("Рекс", 5, "Овчарка");
        
        // Читаем имя через геттер
        System.out.println("Имя собаки: " + собака.getИмя());
        
        // Пытаемся установить неправильный возраст
        собака.setВозраст(150);  // Выведет ошибку
        
        // Устанавливаем правильный возраст
        собака.setВозраст(6);
        System.out.println("Новый возраст: " + собака.getВозраст());
    }
}

Аналогия из жизни: Представьте, что вы — кассир в магазине. У вас есть касса (объект), внутри которой хранятся деньги (поля). Вы не позволяете покупателям самим брать деньги из кассы — они должны попросить вас (вызвать метод), и вы проверите, можно ли выдать сдачу. Это и есть инкапсуляция: вы контролируете доступ к данным.

Принцип №2: Наследование — не изобретай велосипед

В жизни мы часто используем готовые решения. Например, чтобы сделать новый смартфон, компания берёт старую модель и добавляет новые функции. В Java наследование позволяет создать новый класс на основе существующего, взяв все его поля и методы, и добавив что-то своё.

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

В Java наследование обозначается ключевым словом extends.

// Базовый класс (родитель)
public class Животное {
    protected String имя;  // protected — видно в наследниках
    protected int возраст;

    public Животное(String имя, int возраст) {
        this.имя = имя;
        this.возраст = возраст;
    }

    public void есть() {
        System.out.println(имя + " ест.");
    }

    public void спать() {
        System.out.println(имя + " спит.");
    }
}

// Дочерний класс (наследник) — Собака
public class Собака extends Животное {
    private String порода;

    public Собака(String имя, int возраст, String порода) {
        super(имя, возраст);  // Вызов конструктора родителя
        this.порода = порода;
    }

    // Новый метод, которого нет у родителя
    public void лаять() {
        System.out.println(имя + " лает: Гав-гав!");
    }

    // Переопределяем метод родителя (полиморфизм — о нём дальше)
    @Override
    public void есть() {
        System.out.println(имя + " ест корм для собак.");
    }
}

// Дочерний класс — Кошка
public class Кошка extends Животное {
    private String цветШерсти;

    public Кошка(String имя, int возраст, String цветШерсти) {
        super(имя, возраст);
        this.цветШерсти = цветШерсти;
    }

    public void мяукать() {
        System.out.println(имя + " мяукает: Мяу!");
    }

    @Override
    public void есть() {
        System.out.println(имя + " ест рыбу.");
    }
}

Теперь в главной программе мы можем работать с обоими классами:

public class ГлавнаяПрограмма {
    public static void main(String[] args) {
        Собака собака = new Собака("Бобик", 3, "Дворняжка");
        Кошка кошка = new Кошка("Мурка", 2, "Серая");

        // Методы родителя работают у обоих
        собака.есть();  // Выведет: Бобик ест корм для собак.
        кошка.есть();   // Выведет: Мурка ест рыбу.
        
        // Специфические методы
        собака.лаять();  // Бобик лает: Гав-гав!
        кошка.мяукать(); // Мурка мяукает: Мяу!
        
        // Метод спать() унаследован без изменений
        собака.спать();  // Бобик спит.
    }
}

Важно: Ключевое слово super вызывает конструктор или метод родительского класса. Это как сказать: «Сначала сделай всё, что делает родитель, а потом добавь мои изменения».

Аналогия из жизни: Наследование — это как семейные черты. Дети наследуют от родителей цвет глаз, рост, но также приобретают свои уникальные особенности. В программировании то же самое: класс-наследник получает всё от родителя и может добавить или изменить поведение.

Принцип №3: Полиморфизм — один интерфейс, много реализаций

Слово «полиморфизм» происходит от греческих слов «много» и «форма». В Java это означает, что один и тот же метод может работать по-разному в зависимости от того, какой объект его вызывает.

Вы уже видели пример: метод есть() в классе Животное и его переопределение в Собака и Кошка. Когда мы вызываем есть() у собаки, выполняется версия для собаки, а у кошки — для кошки. Это называется переопределение методов (overriding).

Но полиморфизм идёт дальше. Мы можем объявить переменную типа родителя, а хранить в ней объект любого наследника. Это позволяет писать универсальный код.

public class ГлавнаяПрограмма {
    public static void main(String[] args) {
        // Полиморфизм: переменная типа Животное может ссылаться на Собаку или Кошку
        Животное моёЖивотное1 = new Собака("Рекс", 4, "Овчарка");
        Животное моёЖивотное2 = new Кошка("Васька", 3, "Рыжий");

        // Вызываем метод eat() — выполнится версия для конкретного класса
        моёЖивотное1.есть();  // Рекс ест корм для собак.
        моёЖивотное2.есть();  // Васька ест рыбу.

        // Но специфические методы (лаять, мяукать) недоступны через переменную Животное
        // моёЖивотное1.лаять(); — Ошибка компиляции!

        // Чтобы вызвать лаять, нужно явное приведение типа
        if (моёЖивотное1 instanceof Собака) {
            Собака собака = (Собака) моёЖивотное1;
            собака.лаять();  // Рекс лает: Гав-гав!
        }
    }
}

Оператор instanceof проверяет, является ли объект экземпляром определённого класса. Это как спросить: «Ты точно собака?» — и только потом разрешить лаять.

Ещё один пример полиморфизма — перегрузка методов (overloading). Это когда в одном классе есть несколько методов с одинаковым именем, но разными параметрами. Java сама выбирает, какой метод вызвать, в зависимости от переданных аргументов.

public class Калькулятор {
    // Перегрузка метода sum
    public int sum(int a, int b) {
        return a + b;
    }

    public int sum(int a, int b, int c) {
        return a + b + c;
    }

    public double sum(double a, double b) {
        return a + b;
    }
}

public class ГлавнаяПрограмма {
    public static void main(String[] args) {
        Калькулятор кальк = new Калькулятор();
        System.out.println(кальк.sum(5, 10));        // Вызовет первый метод
        System.out.println(кальк.sum(5, 10, 15));    // Вызовет второй
        System.out.println(кальк.sum(5.5, 10.2));    // Вызовет третий
    }
}

Аналогия из жизни: Полиморфизм — это как пульт управления. На пульте есть кнопка «Включить». Для телевизора она включает телевизор, для кондиционера — кондиционер. Одна и та же команда работает по-разному для разных устройств. В Java метод есть() — это такая же кнопка, которая для собаки означает «есть корм», а для кошки — «есть рыбу».

Принцип №4: Абстракция — скрываем сложность

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

В Java есть два инструмента для абстракции: абстрактные классы и интерфейсы.

Абстрактные классы

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

// Абстрактный класс — нельзя создать объект этого класса
public abstract class Транспорт {
    protected String марка;
    protected int скорость;

    public Транспорт(String марка) {
        this.марка = марка;
    }

    // Абстрактный метод — без реализации, должен быть переопределён в наследнике
    public abstract void двигаться();

    // Обычный метод — работает как обычно
    public void информация() {
        System.out.println("Марка: " + марка + ", скорость: " + скорость);
    }
}

// Класс-наследник обязан реализовать абстрактные методы
public class Машина extends Транспорт {
    public Машина(String марка) {
        super(марка);
    }

    @Override
    public void двигаться() {
        System.out.println("Машина " + марка + " едет по дороге со скоростью " + скорость + " км/ч");
    }
}

public class Самолет extends Транспорт {
    public Самолет(String марка) {
        super(марка);
    }

    @Override
    public void двигаться() {
        System.out.println("Самолёт " + марка + " летит в небе со скоростью " + скорость + " км/ч");
    }
}

Теперь мы можем работать с любым транспортом через общий интерфейс:

public class ГлавнаяПрограмма {
    public static void main(String[] args) {
        Транспорт машина = new Машина("Toyota");
        машина.скорость = 120;
        машина.двигаться();  // Выведет: Машина Toyota едет по дороге...

        Транспорт самолет = new Самолет("Boeing");
        самолет.скорость = 900;
        самолет.двигаться();  // Выведет: Самолёт Boeing летит в небе...
    }
}

Интерфейсы — это ещё более абстрактная штука. В интерфейсе объявляются только методы (без реализации), а классы, которые его реализуют, обязаны предоставить код для этих методов. Интерфейс — это как контракт: «Если ты хочешь быть летающим, ты должен уметь летать».

// Интерфейс — описывает, что объект умеет делать, но не как
public interface Летающий {
    void лететь();  // Все методы интерфейса по умолчанию public abstract
    void приземлиться();
}

// Класс может реализовать несколько интерфейсов
public class Птица implements Летающий {
    private String имя;

    public Птица(String имя) {
        this.имя = имя;
    }

    @Override
    public void лететь() {
        System.out.println(имя + " машет крыльями и летит.");
    }

    @Override
    public void приземлиться() {
        System.out.println(имя + " приземляется на ветку.");
    }
}

// Другой класс тоже реализует тот же интерфейс
public class Дрон implements Летающий {
    private String модель;

    public Дрон(String модель) {
        this.модель = модель;
    }

    @Override
    public void лететь() {
        System.out.println("Дрон " + модель + " взлетает с помощью пропеллеров.");
    }

    @Override
    public void приземлиться() {
        System.out.println("Дрон " + модель + " мягко приземляется.");
    }
}

Теперь мы можем написать метод, который принимает любой летающий объект:

public class ГлавнаяПрограмма {
    public static void main(String[] args) {
        Летающий птица = new Птица("Воробей");
        Летающий дрон = new Дрон("DJI Mini");

        // Вызываем метод, не зная, какой конкретно объект
        запуститьПолет(птица);
        запуститьПолет(дрон);
    }

    // Метод принимает любой объект, реализующий интерфейс Летающий
    public static void запуститьПолет(Летающий объект) {
        объект.лететь();
        объект.приземлиться();
    }
}

Аналогия из жизни: Абстракция — это как автомат с газировкой. Вы нажимаете кнопку «Кола» и получаете банку. Вам не нужно знать, как устроен автомат внутри: где стоит насос, как работает охлаждение. Вы просто используете интерфейс (кнопки). В Java интерфейс — это те же кнопки, а классы — это разные автоматы, которые по-разному реализуют нажатие кнопок.

Практические задания

Теперь, когда вы узнали все четыре принципа ООП, пора применить их на практике. Попробуйте выполнить эти задания самостоятельно. Если что-то не получается — перечитайте урок или поищите подсказки в интернете. Удачи!

Задание 1 (лёгкое): Класс «Студент»

Создайте класс Студент с инкапсуляцией:

  • Закрытые поля: имя (String), возраст (int), среднийБалл (double).
  • Конструктор, который принимает все три параметра.
  • Геттеры для всех полей.
  • Сеттер для среднийБалл с проверкой: балл должен быть от 0.0 до 5.0.
  • Метод вывестиИнформацию(), который печатает данные студента.

В главной программе создайте двух студентов и выведите их информацию. Попробуйте установить неправильный балл (например, 10.0) и убедитесь, что сеттер его отвергает.

Задание 2 (среднее): Наследование «Электроника»

Создайте иерархию классов для электронных устройств:

  • Базовый класс Электроника с полями марка и модель, конструктором и методом включить(), который выводит «Устройство включено».
  • Класс Ноутбук, который наследует Электроника. Добавьте поле оперативнаяПамять (int) и метод загрузитьОС(), который выводит «Загрузка Windows...».
  • Класс Смартфон, который наследует Электроника. Добавьте поле диагональЭкрана (double) и метод сделатьФото(), который выводит «Снимок сделан!».