ООП в Java: Классы, Объекты, Инкапсуляция, Наследование и Абстракция

Java постоянно остается одним из трех самых популярных языков программирования в мире. Его применение в областях разработки корпоративного программного обеспечения, мобильных приложений для Android и веб-приложений масштаба предприятия безусловно впечатляет. Его сильная типизация, обширная экосистема и возможность “написать один раз, запустить везде” делают его особенно привлекательным для создания надежных и масштабируемых систем. В этой статье мы рассмотрим, как возможности объектно-ориентированного программирования в Java позволяют разработчикам эффективно использовать эти возможности, позволяя им создавать поддерживаемые и масштабируемые приложения через правильную организацию кода и повторное использование.

Примечание о структуре и запуске Java-кода

Прежде чем мы начнем писать код, давайте сделаем некоторую настройку.

Как и с его синтаксисом, в Java существуют строгие правила относительно организации кода.

Сначала каждый публичный класс должен находиться в собственном файле, названном точно так же, как класс, но с расширением .java. Так что, если я хочу написать класс Laptop, имя файла должно быть Laptop.javaчувствительно к регистру. Вы можете иметь не публичные классы в одном файле, но лучше их разделять. Я знаю, что мы идем впереди—говорим об организации классов даже до их написания—но иметь общее представление о том, где что будет находиться заранее, это хорошая идея.

Во всех проектах на Java должен быть файл Main.java с классом Main. Здесь вы можете тестировать ваши классы, создавая объекты на их основе.

Для запуска Java-кода мы будем использовать IntelliJ IDEA, популярную среду разработки Java. После установки IntelliJ:

  1. Создайте новый проект Java (Файл > Новый > Проект)
  2. Щелкните правой кнопкой мыши на папке src, чтобы создать файл Main.java и вставьте следующее содержимое:
public class Main { public static void main(String[] args) { // Создайте и протестируйте объекты здесь } }

Когда речь идет о классах, мы пишем код в других файлах, чем файл Main.java. Но если речь идет о создании и тестировании объектов, мы переключаемся на Main.java.

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

Результат будет показан в окне инструментов Run внизу.

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

В противном случае, давайте сразу приступим.

Классы и объекты в Java

Итак, что такое классы, точно?

Классы – это программные конструкции на Java для представления понятий реального мира. Например, рассмотрим этот MenuItem класс (создайте файл для написания этого класса в своей среде разработки):

public class MenuItem { public String name; public double price; }

Класс дает нам чертеж или шаблон для представления различных пунктов меню в ресторане. Изменяя два атрибута класса, name, и price, мы можем создавать бесчисленные объекты меню, такие как бургер или салат.

Итак, чтобы создать класс в Java, вы начинаете строку, которая описывает уровень доступа класса (private, public, или protected) за которым следует имя класса. Сразу после скобок вы описываете атрибуты вашего класса.

Но как создать объекты, принадлежащие этому классу? В Java это можно сделать с помощью конструкторов:

public class MenuItem { public String name; public double price; // Конструктор public MenuItem(String name, double price) { this.name = name; this.price = price; } }

Конструктор – это специальный метод, который вызывается при создании нового объекта из класса. Он инициализирует атрибуты объекта значениями, которые мы предоставляем. В приведенном выше примере конструктор принимает параметры name и price и присваивает их полям объекта, используя ключевое слово ‘this’ для ссылки на будущий экземпляр объекта.

Синтаксис конструктора отличается от других методов класса, потому что не требует указывать тип возврата. Кроме того, конструктор должен иметь то же имя, что и класс, и должен иметь то же количество атрибутов, которые вы объявили после определения класса. В приведенном выше примере конструктор создает два атрибута, потому что мы объявили два после определения класса: name и price.

После того, как вы написали свой класс и его конструктор, вы можете создать экземпляры (объекты) в вашем главном методе:

public class Main { public static void main(String[] args) { // Создайте объекты здесь MenuItem burger = new MenuItem("Burger", 3.5); MenuItem salad = new MenuItem("Salad", 2.5); System.out.println(burger.name + ", " + burger.price); } }

Вывод:

Burger, 3.5

Выше мы создаем два MenuItem объекта в переменных burger и salad. Как требуется в Java, тип переменной должен быть объявлен, который равен MenuItem. Затем, чтобы создать экземпляр нашего класса, мы пишем new ключевое слово, за которым следует вызов метода конструктора.

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

public class MenuItem { public String name; public double price; // Конструктор public MenuItem(String name, double price) { this.name = name; this.price = price; } // Метод для расчета цены после налога public double getPriceAfterTax() { double taxRate = 0.08; // Налоговая ставка 8% return price + (price * taxRate); } }

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

public class Main { public static void main(String[] args) { MenuItem burger = new MenuItem("Burger", 3.5); System.out.println("Price after tax: $" + burger.getPriceAfterTax()); } }

Вывод:

Price after tax: $3.78

Инкапсуляция

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

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

// Внутри Main.java MenuItem applePie = new MenuItem("Apple Pie", -5.99); // Отрицательная цена! MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Неразумно дорого System.out.println("Apple pie price: $" + applePie.price); System.out.println("Sandwich price: $" + sandwich.price);

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

Java позволяет нам сделать это с помощью методов-сеттеров:

public class MenuItem { private String name; private double price; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { this.name = name; setPrice(price); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }

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

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

2. Мы добавили новую константу MAX_PRICE, которая:

  • является закрытой (доступной только в пределах класса)
  • статический (общий для всех экземпляров)
  • final (не может быть изменен после инициализации)
  • установлен на $100.0 как разумная максимальная цена

3. Мы добавили setPrice() метод, который:

  • Принимает параметр цены
  • Проверяет, что цена не отрицательная
  • Проверяет, что цена не превышает MAX_PRICE
  • Выбрасывает исключение IllegalArgumentException с пояснительными сообщениями, если проверка не прошла
  • Устанавливает цену только в случае успешного прохождения всех проверок

4. Мы изменили конструктор для использования setPrice() вместо прямого присвоения цены. Это обеспечивает проверку цены при создании объекта.

Мы только что реализовали один из основных принципов хорошего объектно-ориентированного дизайна —инкапсуляцию. Этот подход обеспечивает скрытие данных и контролируемый доступ к атрибутам объекта, гарантируя, что внутренние детали реализации защищены от внешнего вмешательства и могут изменяться только через четко определенные интерфейсы.

Давайте закрепим это, применив инкапсуляцию к атрибуту name. Представьте, что у нас есть кофейня, которая продает только латте, капучино, эспрессо, американо и мокко.

Итак, имена пунктов меню могут быть только одним из элементов этого списка. Вот как мы можем это обеспечить в коде:

// Остальная часть класса здесь ... private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private String name; public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); }

Код выше реализует проверку имени для пунктов меню в кофейне. Давайте разберем его:

1. Сначала он определяет приватный статический финальный массив VALID_NAMES, который содержит единственные допустимые названия напитков: латте, капучино, эспрессо, американо и мокко. Этот массив:

  • приватный: доступен только внутри класса
  • статический: общий для всех экземпляров
  • final: нельзя изменить после инициализации

2. Он объявляет приватное поле String name для хранения названия напитка

3. Метод setName() реализует логику проверки:

  • Принимает параметр String name
  • Преобразует его в нижний регистр для регистронезависимого сравнения
  • Проходит по массиву VALID_NAMES
  • Если совпадение найдено, устанавливает имя и возвращает
  • Если совпадений не найдено, генерирует IllegalArgumentException с описательным сообщением, перечисляющим все допустимые варианты

Вот полный класс на данный момент:

public class MenuItem { private String name; private double price; private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { setName(name); setPrice(price); } public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }

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

public class MenuItem { // Остальной код здесь ... public String getName() { return name; } public double getPrice() { return price; } }

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

Например, без геттеров мы можем получать доступ к атрибутам напрямую:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.name; // Прямой доступ к атрибуту item.name = "INVALID"; // Можно изменять напрямую, обходя валидацию

С геттерами мы обеспечиваем правильный доступ:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.getName(); // Контролируемый доступ через геттер // item.name = "INVALID"; // Не разрешено - нужно использовать setName(), который проводит валидацию

Этот инкапсуляция:

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

Наследование

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

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

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

Например, у нас может быть класс Drink для напитков с опциями температуры, класс Food для позиций, требующих немедленного употребления, и класс Dessert для позиций с особыми требованиями к хранению — все наследующие базовую функциональность пункта меню.

Расширение классов

Давайте реализуем эти идеи, начиная с Drink:

public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { this.name = name; this.price = price; this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }

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

Заметьте, как нам приходится повторять инициализацию name и price вместе с isCold. Это не идеально, потому что у родительского класса может быть сотни атрибутов. Кроме того, вышеуказанный код выдаст ошибку при компиляции, потому что это не правильный способ инициализации атрибутов родительского класса. Правильным способом было бы использование ключевого слова super:

public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }

Ключевое слово super используется для вызова конструктора родительского класса. В этом случае super(name, price) вызывает конструктор MenuItem для инициализации этих атрибутов, избегая дублирования кода. Нам нужно только инициализировать новый атрибут isCold, специфичный для класса Drink.

Ключевое слово очень гибкое, потому что вы можете использовать его для ссылки на родительский класс в любой части дочернего класса. Например, для вызова метода родителя используется super.methodName(), а super.attributeName используется для атрибутов.

Переопределение метода

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

Вот как это выглядит:

public class MenuItem { // Остальная часть класса MenuItem public double calculateTotalPrice() { // Стандартная ставка налога 10% return price * 1.10; } } public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { // На еду налог 15% return super.getPrice() * 1.15; } } public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } @Override public double calculateTotalPrice() { // На напитки налог 8% return super.getPrice() * 1.08; } }

В этом примере переопределение метода позволяет каждому подклассу предоставить свою собственную реализацию calculateTotalPrice():

Базовый MenuItem класс определяет стандартный расчет налога в размере 10%.

Когда Еда и Напиток расширяют класс ПунктМеню, они переопределяют этот метод, чтобы реализовать свои собственные налоговые ставки:

  • На продукты питания установлена более высокая налоговая ставка в 15%
  • Напитки облагаются налогом в размере 8%

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

Каждый подкласс все равно может получить доступ к цене родительского класса, используя super.getPrice(), демонстрируя, как переопределенные методы могут использовать функциональность родительского класса, добавляя при этом свое поведение.

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

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

Наша MenuItem иерархия классов работает, но есть проблема: должен ли кто-то иметь возможность создавать простой MenuItem объект? Ведь в нашем ресторане каждый пункт меню является либо едой, либо напитком – не существует такого понятия как просто “общий пункт меню”.

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

Чтобы сделать MenuItem абстрактным, мы добавляем abstract ключевое слово после его модификатора доступа:

public abstract class MenuItem { private String name; private double price; public MenuItem(String name, double price) { setName(name); setPrice(price); } // Существующие геттеры/сеттеры остаются прежними // Сделайте этот метод абстрактным - каждый подкласс ДОЛЖЕН его реализовать public abstract double calculateTotalPrice(); }

Абстрактные классы также могут иметь абстрактные методы, например calculateTotalPrice() выше. Эти абстрактные методы служат контрактами, которые заставляют подклассы предоставлять свои реализации. Другими словами, любой абстрактный метод в абстрактном классе должен быть реализован дочерними классами.

Итак, давайте перепишемЕда и Напиток с учетом этих изменений:

public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { return getPrice() * 1.15; // 15% налог } } public class Drink extends MenuItem { private boolean hasCaffeine; public Drink(String name, double price, boolean hasCaffeine) { super(name, price); this.hasCaffeine = hasCaffeine; } @Override public double calculateTotalPrice() { return getPrice() * 1.10; // 10% налог } }

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

Заключение

Сегодня мы бросили взгляд на то, на что способен Java как объектно-ориентированный язык программирования. Мы рассмотрели основы классов, объектов и несколько ключевых принципов ООП: инкапсуляцию, наследование и абстракцию через систему меню ресторана.

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

Если вы хотите проверить свои знания Java, попробуйте ответить на некоторые вопросы в нашей статье с вопросами на собеседовании по Java.

Source:
https://www.datacamp.com/tutorial/oop-in-java