Перейти к основному содержимому

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

Разработчику Архитектору

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

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

  1. Всё строго типизировано. Тип переменной фиксирован, проверка на этапе компиляции.
  2. Приватность — реальная. private — действительно нельзя получить доступ извне (без reflection). Инкапсуляция — правило, а не договорённость.
  3. Статическая типизация + одиночное наследование классов. Класс наследует один родитель (extends), но реализует много интерфейсов (implements).

Что обязательно знать:

public class Warrior {
private String name; // приватное поле
private static int count = 0; // статическое поле (общее для всех)

public Warrior(String name) { // конструктор
this.name = name; // this обязателен
count++;
}

public String getName() { return name; } // геттер
public void setName(String name) { this.name = name; }

public static int getCount() { return count; } // статический метод

@Override
public String toString() { // переопределение метода Object
return "Воин: " + name;
}
}

// Наследование (один родитель)
class Knight extends Warrior {
private String horseName;

public Knight(String name, String horseName) {
super(name); // вызов конструктора родителя — ОБЯЗАТЕЛЬНО
this.horseName = horseName;
}

@Override
public String toString() { // переопределение
return super.toString() + " на " + horseName;
}
}

// Интерфейс (контракт)
interface Attackable {
void attack(Warrior target); // по умолчанию public abstract
}

// Реализация нескольких интерфейсов
class Paladin extends Warrior implements Attackable, Healable {
public void attack(Warrior target) { /* ... */ }
public void heal() { /* ... */ }
}

Модификаторы доступа - важнейшая тема:

  • private только внутри класса;
  • нет модификатора - внутри пакета;
  • protected - пакет+наследники;
  • public - везде.
  • поля всегда private, доступ через геттеры/сеттеры.

static - принадлежит классу, а не объекту:

class MathUtils {
public static final double PI = 3.14159; // константа

public static int square(int x) { // статический метод
return x * x;
}
}

// Вызов без создания объекта:
MathUtils.square(5);

Пример класса в Java

public class Unit {
String name = "Имя";
int intel = 10;
int agility = 10;
int strength = 10;
int health = 100;
int mana = 50;
int level = 1;

public int getDamage() {
return (intel + agility + strength) + (level * 2);
}

public void attack(Unit target) {
System.out.println(name + " атакует " + target.name + " и наносит " + getDamage() + " единиц урона.");
target.health -= getDamage();
System.out.println(target.name + " теперь имеет " + target.health + " здоровья.");
}

public static void main(String[] args) {
Unit warrior = new Unit();
warrior.name = "Воин";
warrior.intel = 5;
warrior.agility = 15;
warrior.strength = 30;

Unit mage = new Unit();
mage.name = "Маг";
mage.intel = 35;
mage.agility = 10;
mage.strength = 5;

warrior.attack(mage);
mage.attack(warrior);
}
}

Ключевое слово class определяет новый класс. Модификатор доступа public делает класс доступным из других частей программы. Имя класса начинается с заглавной буквы по соглашению об именовании в Java.

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

Метод getDamage возвращает вычисляемое значение урона. Ключевое слово public делает метод доступным извне класса. Ключевое слово return возвращает результат вычисления вызывающему коду.

Метод attack принимает объект класса Unit в качестве параметра. Метод выводит сообщение о нанесённом уроне и изменяет значение здоровья цели. Метод не возвращает значение, поэтому его тип указан как void.

Метод main является точкой входа в программу. Модификатор static позволяет вызывать метод без создания экземпляра класса. Параметр String[] args принимает аргументы командной строки.

Оператор new выделяет память для нового объекта и вызывает конструктор класса. Конструктор по умолчанию инициализирует поля начальными значениями. После создания объекта можно изменять значения его полей через точечную нотацию.

При запуске программы выполняется метод main. В методе создаются два объекта, настраиваются их характеристики и вызываются методы атаки для демонстрации взаимодействия между объектами.


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

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

Java — один из наиболее ярких представителей строго объектно-ориентированных языков. Практически всё в Java является объектом: исключения, потоки ввода-вывода, коллекции, пользовательские сущности — все они инстанцируются из классов, наследуют поведение, взаимодействуют через интерфейсы. Даже примитивные типы имеют свои объектные обёртки (Integer, Boolean, Character и др.), что упрощает унификацию подходов к работе с данными.

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

Класс

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

Класс не занимает память сам по себе — он всего лишь описание.

Класс — это ссылочный тип, наследуемый от java.lang.Object.

java.lang.Object — корень всей иерархии типов в Java. Все классы неявно наследуются от Object. Это означает, что любой объект в Java обладает базовым набором методов, определённых в этом классе.

Важные методы Object:

  • toString() — возвращает строковое представление объекта. По умолчанию возвращает имя класса и хеш-код. Переопределяется для читаемого вывода.
  • equals(Object obj) — проверяет логическое равенство объектов. По умолчанию сравнивает ссылки. Переопределяется для сравнения по содержимому.
  • hashCode() — возвращает хеш-код объекта. Используется в хеш-таблицах (HashMap, HashSet). Должен быть согласован с equals.
  • getClass() — возвращает объект класса Class, представляющий runtime-тип объекта.
Object obj = "Hello";
System.out.println(obj.getClass().getName()); // java.lang.String
System.out.println(obj.toString()); // Hello

Переопределение toString(), equals() и hashCode() является стандартной практикой при создании собственных классов данных.

Обычный класс

Обычный класс (Concrete Class) это базовая единица построения программы. Класс может быть создан по умолчанию (если не указаны модификаторы доступа abstract или static) и его экземпляры могут создаваться с помощью оператора new.

  • Создание экземпляров: Возможно (new MyClass()).
  • Наследование: Может наследовать другой класс (один родитель) и реализовывать несколько интерфейсов.
  • Методы: Содержит как абстрактные методы (только сигнатуру), так и конкретные (с телом). Если есть хотя бы один абстрактный метод, класс должен быть объявлен как abstract.
  • Конструкторы: Обязательны для создания объектов.
  • Статические члены: Могут содержать статические поля и методы, доступные без создания объекта.
  • Использование: Основной инструмент для инкапсуляции данных и поведения конкретных сущностей.

Выбирайте обычный класс, когда вы создаете конкретную сущность, которую нужно создавать множество раз, и она не должна быть абстракцией.


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

Абстрактный класс (Abstract Class) это класс, который не может быть instantiated (не может быть создан напрямую через new). Он предназначен для определения общего шаблона (скелета) для подклассов.

  • Создание экземпляров: Невозможно. Создается только через наследование.
  • Наследование: Может наследовать только один класс (как и обычный класс). Может быть расширен несколькими классами.
  • Методы: Может содержать:
    • Конкретные методы (с телом).
    • Абстрактные методы (без тела, требуют реализации в потомках).
    • Статические и финальные методы.
  • Конструкторы: Существуют, но вызываются только при создании экземпляра класса-потомка.
  • Поля: Могут быть любого типа (public, protected, private, static, final).
  • Назначение: Использование, когда нужно разделить общую логику (код) и вариативную часть (абстрактные методы) у группы связанных объектов.

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


Статический класс

В Java понятие «статический класс» относится к вложенному статическому классу (Nested Static Class). Сам по себе корневой класс не может быть объявлен static, это применимо только к классам внутри других классов.

Синтаксис:

class Outer {
public static class Inner { }
}
  • Зависимость от внешнего класса: Отсутствует. Вложенный статический класс не имеет скрытой ссылки на экземпляр внешнего класса (Outer.this).
  • Доступ к членам: Может обращаться только к статическим членам внешнего класса. Для доступа к экземплярам внешнего класса требуется явное создание объекта new Outer().
  • Создание экземпляров: new Outer.Inner(). Не требует предварительного создания объекта внешнего класса.
  • Разница с обычным вложенным классом: Обычный вложенный класс (inner class) привязан к экземпляру внешнего класса и может получать доступ ко всем его полям (включая приватные). Статический вложенный класс работает изолированно, как отдельная сущность, просто находящаяся в пространстве имен внешнего класса.
  • Применение: Часто используется для реализации вспомогательных логических групп, хелперов или констант, которые логически связаны с внешним классом, но не зависят от его состояния.

Важное уточнение: В C# существует понятие static class (класс, который нельзя инстанцировать и содержит только статические члены). В Java такой конструкции нет. То, что в C# является static class, в Java реализуется либо как обычный класс со статическими методами (паттерн Utility class), либо как вложенный статический класс, если он нужен внутри другого класса.

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


Интерфейс

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

  • Создание экземпляров: Невозможно напрямую. Реализуется классом или другим интерфейсом.
  • Наследование: Класс может реализовать несколько интерфейсов. Интерфейс может наследовать несколько других интерфейсов.
  • Методы:
    • public abstract: Обязательная реализация (по умолчанию публичные и абстрактные).
    • default: Имеет тело, предоставляет стандартную реализацию (добавлено в Java 8).
    • static: Имеет тело, вызывается через имя интерфейса (добавлено в Java 8).
    • private: Имеет тело, используется внутри интерфейса для переиспользования кога (добавлено в Java 9).
  • Поля: Все поля являются public static final (константами).
  • Конструкторы: Отсутствуют.
  • Назначение: Определение поведения, которое должно быть у разных, возможно несвязанных иерархически, классов. Используется для полиморфизма и разделения ответственности (SOLID принцип Interface Segregation).

Выбирайте interface, если вам нужно определить поведение, которое будет использоваться разными, несвязанными типами объектов (например, Runnable, Comparable). Это обеспечивает максимальную гибкость множественной реализации.


Сравнение классов

ХарактеристикаОбычный классАбстрактный классСтатический вложенный классИнтерфейс
ИнстанцированиеДа (new)НетДа (new Outer.Inner())Нет
Наследование (класс)Один родительОдин родительОдин родитель (внешний)Нет (реализует интерфейсы)
Реализация интерфейсовНесколькоНесколькоНесколькоНесколько
Абстрактные методыДопустимы (только в абстр. классе)ДопустимыДопустимы (если класс не final)Обязательны (кроме default/static)
Конкретные методыДаДаДаДа (default, static, private)
ПоляЛюбой модификаторЛюбой модификаторЛюбой модификаторТолько public static final
КонструкторыЕстьЕстьЕстьНет
Связь с родителемНет (для статического)Нет (для статического)Нет (скрытая ссылка отсутствует)Нет
Основная цельСоздание объектовШаблон + общий кодГруппировка в пространстве именКонтракты / Поведение

Объект

Объект (или экземпляр) — это конкретный представитель класса, созданный в памяти с помощью оператора new.

Синтаксис создания: Тип имяПеременной = new Конструктор()

Car myCar = new Car();

Здесь:

  • myCar — переменная-ссылка, хранящая адрес объекта в куче.
  • new Car() — выделение памяти и вызов конструктора.
myCar.setModel("Tesla");
myCar.setYear(2023);
myCar.startEngine(); // Двигатель запущен!

Класс определяет тип, а объект является экземпляром данного типа.


Конструктор

Конструктор – это специальный метод, который вызывается при создании экземпляра класса. Он используется для инициализации состояния объекта.

Пример класса с конструктором:

class Car {
private String brand;
private String model;

// Конструктор
public Car(String brand, String model) {
this.brand = brand;
this.model = model;
}

public void showInfo() {
System.out.println("Марка: " + brand + ", Модель: " + model);
}
}
// Использование
Car car1 = new Car("BMW", "X5");
car1.showInfo();

Можно иметь несколько конструкторов с разными параметрами — это называется перегрузкой конструкторов.


Атрибуты

Атрибуты – это свойства и поля, которые описывают состояние объекта.

String brand; // поле
int year;
private String model; // поле с ограниченным доступом

Поле

Поле — это переменная, объявленная внутри класса. Хранит состояние объекта.

public class Person {
public String name; // открытое поле
private int age; // приватное поле
}

Свойство (JavaBean convention)

В Java нет встроенного синтаксиса свойств, как в C#. Вместо этого используются методы доступа (геттеры и сеттеры), следующие соглашению JavaBeans.

  • Геттер — метод для чтения значения поля.
  • Сеттер — метод для записи значения поля, часто с валидацией.
public class Person {
private String name;
private int age;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
if (age >= 0) {
this.age = age;
} else {
throw new IllegalArgumentException("Возраст не может быть отрицательным");
}
}
}

Использование:

Person person = new Person();
person.setName("Alice");
System.out.println(person.getName());

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


Метод

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

public void drive() {
System.out.println("Машина едет...");
}

public int calculateAge(int birthYear) {
return java.time.Year.now().getValue() - birthYear;
}

Методы могут принимать переменное количество аргументов (varargs):

public void printNames(String... names) {
for (String name : names) {
System.out.println(name);
}
}
// Вызов:
printNames("Alice", "Bob", "Charlie");
printNames(); // OK — пустой массив

this

Ключевое слово this — это ссылка на текущий объект.

public class Person {
private String name;

public Person(String name) {
this.name = name; // this.name — поле, name — параметр
}

public void printInfo() {
System.out.println("Имя: " + this.name);
}
}

this также используется для вызова другого конструктора того же класса:

public class Person {
private String name;
private int age;

public Person() {
this("Unknown", 0); // вызов другого конструктора
}

public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

Вызов this() должен быть первой строкой в конструкторе.


Порядок объявления

В Java порядок объявления полей, методов и конструкторов внутри класса не влияет на компиляцию. Компилятор анализирует весь класс целиком. Однако для читаемости принято следовать соглашениям:

  1. Константы (static final)
  2. Поля экземпляра
  3. Конструкторы
  4. Методы
  5. Вложенные классы/интерфейсы Также часто группируют члены по видимости: сначала public, затем protected, package-private, private.

Основы ООП в Java

Давайте рассмотрим пример с реализацией принципов ООП в Java:

// Интерфейс задаёт контракт: обязывает реализовать метод
interface IVoice {
String makeSound();
}

// Абстрактный класс: хранит общие поля и поведение, но не создаётся напрямую
abstract class Animal implements IVoice {
protected String name;

public Animal(String name) {
this.name = name;
}

// Абстрактный метод: реализация откладывается до наследников
public abstract void move();
}

// Наследник 1
class Cat extends Animal {
public Cat(String name) {
super(name);
}

@Override
public String makeSound() {
return "Мяу!";
}

@Override
public void move() {
System.out.println(name + " крадётся.");
}
}

// Наследник 2
class Dog extends Animal {
public Dog(String name) {
super(name);
}

@Override
public String makeSound() {
return "Гав!";
}

@Override
public void move() {
System.out.println(name + " бежит.");
}
}

// Точка входа
public class ZooLesson {
public static void main(String[] args) {
Animal cat = new Cat("Мурка");
Animal dog = new Dog("Бобик");

System.out.println(cat.makeSound());
cat.move();

System.out.println(dog.makeSound());
dog.move();
}
}

Здесь мы видим реализацию концепций:

  1. Интерфейс (IVoice) — содержит только сигнатуру метода. Описывает, что объект умеет делать, без реализации.
  2. Абстрактный класс (Animal) — реализует интерфейс, но помечен как abstract. Позволяет хранить общие данные (name), конструктор и абстрактный метод (move()). Экземпляр создать нельзя: new Animal() вызовет ошибку компиляции.
  3. Наследование (extends)Cat и Dog получают поля и конструктор Animal через super(name). Каждый наследник обязан реализовать все абстрактные методы и методы интерфейса.
  4. Полиморфизм — в main переменные объявлены как Animal, но хранят объекты Cat и Dog. Вызов методов определяется во время выполнения (dynamic dispatch), что позволяет работать с разными типами через общий интерфейс.

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

  • IVoice определяет обязанности для объектов;
  • Animal реализует интерфейс и выступает верхушкой иерархии;
  • Cat и Dog - наследники класса Animal;
  • Именно на основе Cat или Dog создаются объекты в памяти.

Когда текущий объект будет создан, у него появится свой набор данных. Это контекст.


Контекст текущего объекта

Контекст текущего объекта — это доступ к полям и методам конкретного объекта через ссылку this. Каждый объект хранит свои данные отдельно.

class Person {
String name;

void greet() {
System.out.println("Привет, я " + this.name);
// this указывает на КОНКРЕТНЫЙ объект, который вызвал метод
}
}

Person p1 = new Person();
p1.name = "Анна";
Person p2 = new Person();
p2.name = "Борис";

p1.greet(); // Выведет: Привет, я Анна (this ссылается на p1)
p2.greet(); // Выведет: Привет, я Борис (this ссылается на p2)

Внешний и вложенный класс

Экземпляр внешнего класса — когда вложенный класс создаётся внутри объекта внешнего класса и автоматически получает ссылку на него.

class Outer {
private String outerField = "Я из внешнего класса";

class Inner { // Нестатический вложенный класс
void show() {
// Inner автоматически имеет доступ к outerField
// Он хранит ссылку на конкретный объект Outer
System.out.println(outerField);
// или явно: Outer.this.outerField
}
}
}

// Создание:
Outer outer = new Outer(); // Создаём ВНЕШНИЙ объект
Outer.Inner inner = outer.new Inner(); // Создаём ВНУТРЕННИЙ через ВНЕШНИЙ
inner.show(); // Работает, потому что inner знает, какой outer его создал

Всё это в комплексе позволяет выстраивать любые системы.


Наследование и вложенность

Давайте разберём комплексный пример, демонстрирующий наследование (классы и интерфейсы), вложенность (обычный и статический класс) и ключевые различия между ними.

// 1. Интерфейс - контракт поведения
interface Flyable {
void fly(); // Абстрактный метод
default void hover() { // Метод по умолчанию
System.out.println("Летим с замиранием");
}
}

// 2. Абстрактный класс - общий шаблон + часть реализации
abstract class Vehicle {
protected String brand;

public Vehicle(String brand) {
this.brand = brand;
}

// Конкретный метод: общая логика для всех транспортных средств
public void startEngine() {
System.out.println(brand + ": Двигатель запущен");
}

// Абстрактный метод: должен быть реализован потомком
public abstract void move();
}

// 3. Обычный класс - конкретная реализация
class Car extends Vehicle implements Flyable {

private int wheels;

public Car(String brand, int wheels) {
super(brand);
this.wheels = wheels;
}

@Override
public void move() {
System.out.println("Машина " + brand + " едет на " + wheels + " колесах");
}

@Override
public void fly() {
System.out.println("Машина " + brand + " летит (гипотетически)");
}
}

// 4. Вложенные классы внутри внешнего класса Container
class Container {
private String name;

public Container(String name) {
this.name = name;
}

// --- ОБЫЧНЫЙ ВНУТРЕННИЙ КЛАСС (Inner Class) ---
// Имеет доступ ко всем полям и методам экземпляра Container
class InnerClass {
public void showInfo() {
// Доступ к приватному полю внешнего класса
System.out.println("Внутренний класс видит имя контейнера: " + name);
}
}

// --- СТАТИЧЕСКИЙ ВНУТРЕННИЙ КЛАСС (Static Nested Class) ---
// НЕ имеет доступа к экземпляру Container. Видит только статику.
static class StaticNestedClass {
// Нельзя обратиться к 'name' здесь, так как это не статика
// public void showInfo() { System.out.println(name); } // Ошибка компиляции

public void showInfo() {
System.out.println("Статический вложенный класс работает изолированно.");
}

// Может использовать статические члены Container
public void useStaticField() {
// System.out.println(Container.STATIC_CONST); // Если бы он был
}
}
}

// Точка входа для демонстрации
public class Main {
public static void main(String[] args) {

// --- Пример 1: Наследование и Реализация ---
// Создаем объект обычного класса, который наследует от абстрактного и реализует интерфейс
Car myCar = new Car("Toyota", 4);
myCar.startEngine(); // Вызов из родительского класса
myCar.move(); // Реализация абстрактного метода
myCar.fly(); // Реализация метода интерфейса
myCar.hover(); // Использование метода по умолчанию из интерфейса

// --- Пример 2: Обычный вложенный класс (Inner Class) ---
Container container = new Container("Box-1");

// Для создания объекта внутреннего класса нужен экземпляр внешнего
Container.InnerClass inner = container.new InnerClass();
inner.showInfo(); // Выведет имя контейнера, потому что есть ссылка на instance

// --- Пример 3: Статический вложенный класс (Static Nested Class) ---
// Создается напрямую, без экземпляра Container
Container.StaticNestedClass staticObj = new Container.StaticNestedClass();
staticObj.showInfo();

// Попытка создать обычный вложенный класс без экземпляра родителя вызовет ошибку:
// Container.InnerClass error = new Container.InnerClass(); // Не скомпилируется
}
}

1. Наследование (extends vs implements)

  • Абстрактный класс (Vehicle): Используется для передачи общего состояния (brand) и общей логики (startEngine). Класс может наследовать только один такой класс. Это связь «является» (Is-A): Машина является Транспортным средством.
  • Интерфейс (Flyable): Используется для добавления дополнительного поведения, которое может быть у несвязанных объектов. Класс может реализовать несколько интерфейсов. Это способность (Can-Do): Машина может летать (в данном примере).
  • Обычный класс (Car): Конечный продукт, объединяющий оба механизма.

2. Вложенность (Inner vs Static Nested)

Это критически важное различие в контексте связности с внешним классом.

ХарактеристикаОбычный вложенный класс (InnerClass)Статический вложенный класс (StaticNestedClass)
СозданиеТребует экземпляр внешнего класса: outer.new Inner()Не требует: new Outer.StaticNested()
Доступ к даннымВидит все поля и методы внешнего класса (включая private).Видит только статические поля и методы внешнего класса.
Скрытая ссылкаИмеет скрытую ссылку на внешний объект (Outer.this).Ссылки на внешний объект нет.
ПамятьКаждый экземпмер внутреннего класса хранит ссылку на экземпляр внешнего, что увеличивает потребление памяти.Не хранит ссылку, ведет себя как отдельный класс в пространстве имен.
ИспользованиеКогда логика тесно связана с состоянием внешнего объекта (например, итератор внутри коллекции).Когда класс является вспомогательным, хелпером или константой, логически связанной с внешним классом, но независимой от его состояния.

3. Почему нельзя сделать static class на верхнем уровне?

В коде выше вы видите class Container, а внутри него static class. Если попытаться объявить корневой класс как static:

// ОШИБКА КОМПИЛЯЦИИ
static class TopLevel { ... }

Java не поддерживает статические классы на уровне пакета (top-level classes). Модификатор static применим только к членам другого класса (вложенным классам).

  • Если вам нужен класс, который содержит только статические методы (как Math или Collections), вы просто делаете конструктор private и все методы static в обычном классе. Это паттерн Utility Class.

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

Инкапсуляция — это принцип, направленный на достижение двух целей:

  1. Ограничение прямого доступа к внутреннему состоянию объекта, чтобы предотвратить его некорректное изменение извне.
  2. Гарантия согласованности состояния объекта, то есть поддержание инвариантов — условий, которые должны оставаться истинными на протяжении всего жизненного цикла объекта.

Управление доступом к членам класса критически важно для инкапсуляции.

Без инкапсуляции:

public class BankAccount {
public double balance; // Прямо доступно!
}
...
var account = new BankAccount();
account.balance = -1000; // Ошибка логики!

С инкапсуляцией:

public class BankAccount {
private double balance;

public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}

public double getBalance() {
return balance;
}
}

В Java инкапсуляция реализуется на уровне класса с использованием модификаторов доступа:

  • private,
  • protected,
  • public,
  • package-private (отсутствие модификатора).

Шаблоны следующие:

<модификатор класса> class <имя класса>
<модификатор доступа> <тип> <имя поля>
<модификатор доступа> <имя класса>(<параметры>)
<модификатор доступа> <тип> get<ИмяСвойства>()
<модификатор доступа> void set<ИмяСвойства>(<тип> <параметр>)
<тип> <имя метода>() // package-private (модификатор отсутствует)

protected <тип> <имя метода>()
private <тип> <имя метода>()
public <тип> <имя метода>()

Соответствия спецификации Java:

  • package-private не имеет ключевого слова; видимость определяется на уровне пакета компиляции.
  • protected обеспечивает доступ внутри пакета + в классах-наследниках (в том числе за пределами пакета).
  • private ограничивает доступ рамками одного класса; внешнее взаимодействие реализуется через public/protected аксессоры и мутаторы.
  • Конвенция именования геттеров/сеттеров соответствует JavaBeans: get<ИмяСвойства>() / set<ИмяСвойства>(<тип> значение).
  • Валидация в сеттере является стандартным механизмом сохранения инвариантов объекта при инкапсуляции состояния.

private

private — доступ только внутри класса. Наиболее строгий уровень.

public class Logger {
private String logFile = "app.log";

private void writeToFile(String message) {
// логика записи
}

public void log(String message) {
writeToFile("[" + java.time.LocalDateTime.now() + "] " + message);
}
}

public

public — доступ отовсюду.

public class Calculator {
public int add(int a, int b) {
return a + b;
}
}

protected

protected — доступ внутри пакета и в наследниках (даже если они в другом пакете).

public class Animal {
protected String name;

protected Animal(String name) {
this.name = name;
}

protected void makeSound() {
System.out.println("Звук...");
}
}

public class Dog extends Animal {
public Dog(String name) {
super(name);
}

@Override
protected void makeSound() {
System.out.println(name + " лает!");
}
}

package-private (default)

Отсутствие модификатора означает доступ только в пределах текущего пакета. Используется для вспомогательных классов, не предназначенных для внешнего использования.

class DatabaseHelper {
String connectionString = "jdbc:mysql://localhost";
}

static

Ключевое слово static означает, что член принадлежит классу, а не экземпляру.

Статические поля

Хранят общее состояние для всех экземпляров.

public class Counter {
private static int totalCount = 0;
private int instanceId;

public Counter() {
instanceId = ++totalCount;
}

public static int getTotalCount() {
return totalCount;
}
}

Статические методы

Не требуют создания экземпляра. Не могут обращаться к нестатическим полям напрямую.

public class MathUtils {
public static double square(double x) {
return x * x;
}
}
double result = MathUtils.square(5);

Статические блоки инициализации

Выполняются один раз при загрузке класса.

public class Config {
private static String apiUrl;

static {
// Чтение конфигурации из файла или системы
apiUrl = System.getenv("API_URL");
if (apiUrl == null) {
apiUrl = "https://default.api.com";
}
}

public static String getApiUrl() {
return apiUrl;
}
}

Статические вложенные классы

Вложенный класс, помеченный static, не имеет ссылки на внешний класс.

public class Outer {
private static String staticField = "Static";

public static class StaticNested {
public void print() {
System.out.println(staticField); // Доступ к статическим полям внешнего класса
}
}
}

Наиболее строгий уровень — private, и именно он является рекомендованным по умолчанию для всех полей экземпляра. Дело в том, что открытое (public) поле превращает внутреннее состояние объекта в часть его публичного контракта. Любое изменение типа, семантики или логики такого поля становится потенциально ломающим изменением для всех клиентов класса.

Рассмотрим следующий пример:

public class BankAccount {
private String accountNumber;
private BigDecimal balance;

public BankAccount(String accountNumber, BigDecimal initialBalance) {
if (initialBalance.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Начальный баланс не может быть отрицательным");
}
this.accountNumber = Objects.requireNonNull(accountNumber, "Номер счёта не может быть null");
this.balance = initialBalance;
}

public BigDecimal getBalance() {
return balance;
}

public void deposit(BigDecimal amount) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Сумма пополнения должна быть положительной");
}
this.balance = this.balance.add(amount);
}

public void withdraw(BigDecimal amount) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Сумма снятия должна быть положительной");
}
if (this.balance.compareTo(amount) < 0) {
throw new IllegalStateException("Недостаточно средств на счету");
}
this.balance = this.balance.subtract(amount);
}
}

Здесь поля accountNumber и balance объявлены как private, а доступ к ним контролируется через конструктор и методы. Обратите внимание: отсутствует сеттер для balance. Это не упущение — это осознанное проектировочное решение. Баланс не должен устанавливаться «произвольно» извне. Его изменение допустимо только через семантически осмысленные операции — deposit() и withdraw(), каждая из которых проверяет корректность и сохраняет инварианты (например, баланс не может стать отрицательным при снятии, если явно не разрешено овердрафт).

Метод getBalance() возвращает значение, а не ссылку на внутренний BigDecimal. Это важно, потому что BigDecimal хотя и неизменяем, но в случае изменяемых объектов (например, List, Date, StringBuilder) возврат ссылки на приватное поле может нарушить инкапсуляцию: внешний код получит возможность модифицировать внутреннее состояние напрямую. В таких случаях применяют защитное копирование (new ArrayList<>(internalList), date.clone() — осторожно, clone() проблематичен — или Instant вместо Date).

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

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


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

Наследование в Java — это механизм, позволяющий одному классу (подклассу, или наследнику) унаследовать поля и методы другого класса (суперкласса, или родителя). Это достигается с помощью ключевого слова extends.

Таким образом, имея один класс:

class Parent {
public void parentMethod {
//Это метод родителя
}
}

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

class Child extends Parent {
@Override
public void parentMethod {
System.out.println("Мы переопределили метод родителя!");
}

public void childMethod {
System.out.println("А это метод наследника.");
}
}

super

Ключевое слово super — ссылка на суперкласс.

  1. Вызов конструктора суперкласса:
public class Animal {
public Animal(String name) {
this.name = name;
}
}

public class Dog extends Animal {
public Dog(String name) {
super(name); // Обязательный вызов, если нет конструктора без аргументов
}
}
  1. Вызов переопределённого метода суперкласса:
public class Dog extends Animal {
@Override
public void makeSound() {
super.makeSound(); // Сначала звук животного
System.out.println("Гав!"); // Потом специфичный звук
}
}

Давайте посмотрим на основные шаблоны:

  1. class <имя подкласса> extends <имя суперкласса> — базовый синтаксис одиночного наследования.
  2. class <имя класса> implements ... — шаблон множественного наследования поведения через интерфейсы (альтернатива наследованию классов).
  3. @Override ... — строгий шаблон переопределения метода. Аннотация обязательна для контроля соблюдения контракта на этапе компиляции.
  4. super(...) и super. — шаблоны явного обращения к конструктору и членам родительского класса. Критичны для цепочки инициализации.
  5. <тип> ... = new <имя подкласса>(...) — демонстрация полиморфизма: ссылка типа суперкласса указывает на объект подкласса.
  6. instanceof и isAssignableFrom — шаблоны безопасной проверки типов перед приведением (кастингом), чтобы избежать ClassCastException.
  7. final class / final method — шаблоны запрета наследования или переопределения (нарушение расширяемости ради безопасности/производительности).
  8. abstract class / abstract method — шаблоны определения неполного контракта, требующего реализации в наследниках.

Наследование решает две основные задачи:

  1. Повторное использование кода — избегание дублирования общей функциональности.
  2. Моделирование «является»-отношения (is-a relationship) — например, «собака является животным», «кредитный счёт является банковским счётом».

Важно понимать: наследование — это не инструмент для «доступа» к полям другого класса. Это контракт на расширение поведения. Подкласс не просто получает доступ к членам суперкласса — он обязуется соблюдать его контракт и может его уточнять, но не нарушать.


Правила наследования в Java

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

// Родительский класс
class Animal {
protected String name;

public Animal(String name) {
this.name = name;
}

public void makeSound() {
System.out.println("Some generic animal sound");
}
}

// Подкласс может наследовать только один суперкласс
class Dog extends Animal {
public Dog(String name) {
super(name); // вызов конструктора суперкласса
}

@Override
public void makeSound() {
System.out.println("Woof!");
}
}

Здесь Dog наследует Animal. Попытка унаследовать ещё один класс приведёт к ошибке компиляции.

Однако множественное наследование поведения возможно через интерфейсы. Начиная с Java 8, интерфейсы могут содержать методы с реализацией по умолчанию (default), что позволяет классу «наследовать» поведение от нескольких источников.

interface Swimmable {
default void swim() {
System.out.println("Swimming...");
}
}

interface Runnable {
default void run() {
System.out.println("Running...");
}
}

// Класс может реализовать несколько интерфейсов
class Duck extends Animal implements Swimmable, Runnable {
public Duck(String name) {
super(name);
}

@Override
public void makeSound() {
System.out.println("Quack!");
}
}

// Использование
Duck duck = new Duck("Donald");
duck.swim(); // Swimming...
duck.run(); // Running...
duck.makeSound(); // Quack!

Это демонстрирует множественное наследование поведения, но не состояния (поля в интерфейсах — либо public static final, либо отсутствуют).

Подкласс наследует все public и protected члены суперкласса (поля, методы, вложенные классы), а также package-private члены — но только если находится в том же пакете.

// Файл: animals/Animal.java
package animals;

public class Animal {
public String species = "Unknown";
protected int age = 0;
String habitat = "Wild"; // package-private
private String secret = "Only for me"; // не наследуется напрямую

public void introduce() {
System.out.println("I am a " + species);
}
}
// Файл: animals/Dog.java
package animals;

public class Dog extends Animal {
public void showDetails() {
System.out.println("Species: " + species); // OK — public
System.out.println("Age: " + age); // OK — protected
System.out.println("Habitat: " + habitat); // OK — package-private и тот же пакет
// System.out.println(secret); // Ошибка — private недоступен
}
}

Если бы Dog находился в другом пакете, доступ к habitat был бы запрещён.

Конструкторы не наследуются. При создании экземпляра подкласса неявно или явно вызывается конструктор суперкласса (через super()).

class Vehicle {
protected String brand;

// Явный конструктор
public Vehicle(String brand) {
this.brand = brand;
System.out.println("Vehicle constructor called");
}
}

class Car extends Vehicle {
private int doors;

// Конструктор Car обязан вызвать конструктор Vehicle
public Car(String brand, int doors) {
super(brand); // обязательный вызов суперконструктора
this.doors = doors;
System.out.println("Car constructor called");
}
}

// Использование
Car myCar = new Car("Toyota", 4);
// Вывод:
// Vehicle constructor called
// Car constructor called

Если в суперклассе нет конструктора без параметров, подкласс обязан вызвать super(...) явно.

Если конструктор подкласса не вызывает super() явно, компилятор автоматически вставляет вызов super() без аргументов.

class Parent {
public Parent() {
System.out.println("Parent default constructor");
}
}

class Child extends Parent {
public Child() {
// Компилятор добавляет: super();
System.out.println("Child constructor");
}
}

Однако если в Parent нет конструктора без параметров, такой код не скомпилируется.


Переопределение методов и контракт Лисков

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

  • Сигнатура метода (имя, список параметров, типы параметров) должна быть идентична.
  • Возвращаемый тип должен быть либо таким же, либо ковариантным (подтипом исходного возвращаемого типа — например, ObjectString).
  • Уровень доступа не может быть более строгим (нельзя переопределить public метод как protected).
  • Исключения, объявленные в throws, не могут быть шире (можно выбрасывать подтипы, но не надтипы).

virtual, override, final

В Java все нестатические, не-private и не-final методы являются виртуальными по умолчанию.

  • @Override — аннотация, указывающая на намерение переопределить метод. Помогает избежать ошибок при изменении сигнатуры в родителе.
  • final — запрещает переопределение метода или наследование класса.
public final class ImmutableClass {
// От этого класса нельзя наследоваться
}

public class Parent {
public final void cannotOverride() {
// Этот метод нельзя переопределить
}
}

Корректное определение выглядит так:

class Animal {
public Object makeSound() {
return "Some generic sound";
}
}

class Dog extends Animal {
@Override
public String makeSound() { // ковариантный возвращаемый тип: String ⊂ Object
return "Woof!";
}
}

Здесь:

  • сигнатура makeSound() совпадает;
  • возвращаемый тип String — подтип Object;
  • уровень доступа остался public.

Нарушение сигнатуры — перегрузка, а не переопределение. Пример:

class Bird {
public void fly(int altitude) {
System.out.println("Flying at " + altitude + " meters");
}
}

class Eagle extends Bird {
public void fly(double altitude) { // другой тип параметра → перегрузка
System.out.println("Soaring at " + altitude + " meters");
}
}

Метод fly(double) не переопределяет fly(int), потому что сигнатуры различаются. Это перегрузка, а не переопределение.

Уменьшение уровня доступа — ошибка компиляции. Пример:

class Vehicle {
public void start() {
System.out.println("Engine started");
}
}

class Car extends Vehicle {
// ❌ ОШИБКА КОМПИЛЯЦИИ
protected void start() { // нельзя сузить доступ с public до protected
System.out.println("Car engine started");
}
}

Java запрещает делать переопределённый метод менее доступным, чем в суперклассе.

Расширение списка исключений — ошибка компиляции:

import java.io.IOException;

class FileReader {
public void read() throws IOException {
System.out.println("Reading file...");
}
}

class SecureFileReader extends FileReader {
// ❌ ОШИБКА КОМПИЛЯЦИИ
@Override
public void read() throws Exception { // Exception шире IOException
System.out.println("Secure reading...");
}
}

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

Вот как нужно сужать исключения:

import java.io.IOException;
import java.io.FileNotFoundException;

class DataProcessor {
public void process() throws IOException {
System.out.println("Processing Данные...");
}
}

class ImageProcessor extends DataProcessor {
@Override
public void process() throws FileNotFoundException { // подтип IOException
System.out.println("Processing image...");
}
}

Это допустимо: FileNotFoundException — подкласс IOException.

Переопределение с тем же возвращаемым типом:

class Shape {
public double getArea() {
return 0.0;
}
}

class Circle extends Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double getArea() { // тот же тип double
return Math.PI * radius * radius;
}
}

Сигнатура идентична, возвращаемый тип совпадает — всё корректно.

Аннотация @Override не обязательна, но настоятельно рекомендуется: она помогает компилятору проверить, действительно ли метод переопределяет родительский.

class Parent {
public void greet() {
System.out.println("Hello from Parent");
}
}

class Child extends Parent {
@Override
public void greet() {
System.out.println("Hello from Child");
}
}

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

Особое значение имеет принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP): объекты подкласса должны быть взаимозаменяемы с объектами суперкласса без изменения корректности программы. Это означает, что переопределённый метод не должен:

  • Ужесточать предусловия (например, требовать больше аргументов или более строгие проверки входных данных).
  • Ослаблять постусловия (например, возвращать меньше информации или в более узком формате).
  • Изменять семантику вызова (например, метод close() не должен начинать подключение к ресурсу).

Нарушение LSP приводит к хрупкому коду, где полиморфизм работает непредсказуемо. Пример анти-паттерна:

class Rectangle {
protected int width, height;

public void setWidth(int w) { width = w; }
public void setHeight(int h) { height = h; }
public int getArea() { return width * height; }
}

class Square extends Rectangle {
@Override
public void setWidth(int w) {
super.setWidth(w);
super.setHeight(w); // ← нарушает контракт Rectangle: setWidth не должен менять height
}

@Override
public void setHeight(int h) {
super.setHeight(h);
super.setWidth(h); // ← аналогично
}
}

Клиентский код, рассчитывающий на поведение Rectangle, может сломаться:

Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
System.out.println(r.getArea()); // ожидаем 20, получаем 16

Таким образом, Square не должен наследоваться от Rectangle — несмотря на кажущееся «является»-отношение в реальном мире, в программной модели это нарушает контракт. Корректнее использовать композицию или вынести общее в абстрактный класс/интерфейс с другим именем (например, Shape с методом getArea()).


Конструкторы и цепочка инициализации

При создании объекта подкласса сначала вызывается конструктор суперкласса — неявно (super()) или явно (super(args)). Это гарантирует, что базовая часть объекта инициализирована до специфичной. Если суперкласс не имеет конструктора без параметров, подкласс обязан явно вызвать один из доступных конструкторов суперкласса в первой строке своего конструктора.

class Vehicle {
protected String brand;

public Vehicle(String brand) {
this.brand = brand;
}
}

class Car extends Vehicle {
private int doors;

public Car(String brand, int doors) {
super(brand); // ← обязательно
this.doors = doors;
}
}

Невыполнение этого требования приведёт к ошибке компиляции.


Когда использовать наследование — и когда избегать

Наследование оправдано, когда:

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

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

Представим, что у нас есть разные типы двигателей (бензиновый, электрический), и автомобиль может использовать любой из них. Вместо того чтобы создавать иерархию Car extends GasEngine или ElectricCar extends ElectricEngine, мы используем композицию.

// Интерфейс двигателя
interface Engine {
void start();
void stop();
}

// Конкретная реализация: бензиновый двигатель
class GasEngine implements Engine {
@Override
public void start() {
System.out.println("Запуск бензинового двигателя");
}

@Override
public void stop() {
System.out.println("Остановка бензинового двигателя");
}
}

// Конкретная реализация: электродвигатель
class ElectricEngine implements Engine {
@Override
public void start() {
System.out.println("Запуск электродвигателя");
}

@Override
public void stop() {
System.out.println("Остановка электродвигателя");
}
}

// Автомобиль "имеет" двигатель — композиция
class Car {
private Engine engine;

// Двигатель передаётся при создании — гибкость!
public Car(Engine engine) {
this.engine = engine;
}

public void start() {
engine.start();
}

public void stop() {
engine.stop();
}

// Возможность заменить двигатель во время выполнения
public void setEngine(Engine engine) {
this.engine = engine;
}
}

Вот как будет выглядеть использование:

public class Main {
public static void main(String[] args) {
Car gasCar = new Car(new GasEngine());
gasCar.start(); // Запуск бензинового двигателя

Car electricCar = new Car(new ElectricEngine());
electricCar.start(); // Запуск электродвигателя

// Можно даже поменять двигатель "на лету"
gasCar.setEngine(new ElectricEngine());
gasCar.start(); // Теперь запускается электродвигатель
}
}

Почему это лучше наследования?

  • Гибкость: поведение можно изменить без создания новых подклассов.
  • Инкапсуляция сохраняется: Car не зависит от внутренней реализации Engine.
  • Нет жёсткой привязки к иерархии: не нужно плодить классы вроде GasCar, HybridCar, ElectricSUV и т.д.
  • Легко тестируется: можно подставить мок-объект Engine в тестах.
  • Соблюдается принцип "Favor composition over inheritance" из SOLID.

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


Полиморфизм

Полиморфизм позволяет объектам разных классов обрабатываться как объекты одного типа, выполняя «свою» версию метода.

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

В Java реализованы два вида полиморфизма:

  1. Статический полиморфизм (перегрузка методов — overloading)
  2. Динамический полиморфизм (переопределение методов — overriding)

Эти два механизма часто путают, хотя они принципиально различаются уровнем связывания (binding), временем разрешения и целями применения.


Статический полиморфизм: перегрузка (overloading)

public class Printer {
public void print(int i) { System.out.println(i); }
public void print(String s) { System.out.println(s); }
public void print(double d) { System.out.println(d); }
}

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

Пример корректной перегрузки:

public class Calculator {
public int add(int a, int b) {
return a + b;
}

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

public String add(String a, String b) {
return a + b;
}

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

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

  • Точном совпадении типов аргументов.
  • Автоматическом приведении (например, intlong, floatdouble).
  • Упаковке/распаковке примитивов (intInteger).
  • Вариадических аргументах (T...) — как крайний вариант.

Важно: при перегрузке не рассматривается фактический тип объекта во время выполнения. Решение принимается по объявленному типу переменных и литералов.

Object obj1 = "hello";
Object obj2 = "world";

Calculator calc = new Calculator();
// calc.add(obj1, obj2); ← ошибка компиляции: нет метода add(Object, Object)
// Даже если obj1 и obj2 — строки, компилятор видит только Object

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

public void process(Integer i) { System.out.println("Integer"); }
public void process(long l) { System.out.println("long"); }

process(5); // → "long", а не "Integer"!
// Потому что int → long (расширение) имеет приоритет над int → Integer (упаковка)

Такие ситуации требуют внимательного проектирования сигнатур и документирования.


Динамический полиморфизм

Динамический полиморфизм — сердцевина ООП. Он позволяет писать код, ориентированный на абстракции, а не на конкретные реализации. Это достигается за счёт:

  • Наследования (или реализации интерфейса).
  • Переопределения методов в подклассах.
  • Использования ссылок на суперкласс/интерфейс для работы с экземплярами подклассов.

Ключевой механизм — позднее (динамическое) связывание. Выбор конкретной реализации метода происходит во время выполнения, на основе фактического типа объекта, на который ссылается переменная.

Пример:

Animal animal = new Dog(); // ← ссылка типа Animal, объект типа Dog
animal.makeSound(); // → "Гав!"

С точки зрения компилятора: проверяется, что у типа Animal есть метод makeSound().
С точки зрения JVM: при вызове animal.makeSound() динамически определяется, что animal ссылается на экземпляр Dog, и вызывается переопределённая версия метода из Dog.

Этот механизм реализован через таблицу виртуальных методов (vtable), хранящуюся в каждом классе. При вызове нестатического (non-static), неприватного (non-private), незапечатанного (final) метода JVM обращается к vtable объекта и извлекает адрес нужной реализации.


Требования к переопределению (повторим для точности)

Для того чтобы метод в подклассе считался переопределением (а не просто методом с тем же именем), должны выполняться:

  • Совпадение имени и сигнатуры (включая generic-параметры после стирания — см. Type Erasure).
  • Совместимость возвращаемого типа (ковариантность допустима).
  • Уровень доступа не строже, чем у метода в суперклассе.
  • В throws — не могут указываться проверяемые (checked) исключения, не объявленные в суперклассе (можно подтипы, можно unchecked).

Аннотация @Override не обязательна, но крайне рекомендуется: она сообщает компилятору, что программист намеревался переопределить метод, и вызывает ошибку, если сигнатура не совпадает (например, опечатка в имени или несоответствие параметров). Это мощный инструмент защиты от регрессий при рефакторинге.


Полиморфизм и интерфейсы

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

interface Drawable {
void draw();
}

class Circle implements Drawable {
public void draw() { System.out.println("Рисуем круг"); }
}

class Chart implements Drawable {
public void draw() { System.out.println("Рисуем диаграмму"); }
}

List<Drawable> items = Arrays.asList(new Circle(), new Chart());
for (Drawable d : items) {
d.draw(); // полиморфный вызов
}

Здесь нет иерархии «круг — диаграмма», но есть общий контракт на поведение. Это позволяет строить гибкие, слабосвязанные системы, где зависимости направлены на абстракции, а не на реализации (принцип DIP — Dependency Inversion Principle из SOLID).


Ограничения полиморфизма в Java

  • static-методы не участвуют в полиморфизме — они связываются статически. Вызов Animal.staticMethod() всегда вызовет Animal.staticMethod(), даже если ссылка указывает на Dog.
  • private-методы не могут быть переопределены — они «невидимы» для подклассов. Если подкласс объявляет метод с тем же именем и сигнатурой, это новый метод, не связанный с родительским.
  • final-методы и final-классы запрещают переопределение — компилятор может выполнить раннее связывание (инлайнинг), что повышает производительность, но снижает гибкость.
  • Конструкторы не полиморфны — при создании new Dog() вызывается конструктор Dog, но внутри него — конструктор Animal. Однако в конструкторе Animal вызов this.makeSound() не вызовет переопределённую версию из Dog, если makeSound() не final или private — это приведёт к вызову переопределённого метода до полной инициализации объекта, что опасно. Такие вызовы стоит избегать.

Абстракция

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

В Java абстракция достигается двумя основными средствами:

  • Абстрактные классы (abstract class)
  • Интерфейсы (interface)

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


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

Абстрактный класс — это класс, который не может быть инстанцирован напрямую и может содержать как реализованные, так и абстрактные методы (без тела, с ключевым словом abstract). Подклассы обязаны реализовать все абстрактные методы, если сами не объявлены abstract.

abstract class ReportGenerator {
protected String title;

public ReportGenerator(String title) {
this.title = title;
}

// общая реализация — неизменяемая часть алгоритма
public final String generate() {
StringBuilder sb = new StringBuilder();
sb.append("=== ").append(title).append(" ===\n");
sb.append(generateHeader()).append("\n");
sb.append(generateBody()).append("\n");
sb.append(generateFooter());
return sb.toString();
}

// абстрактные методы — вариативная часть, реализуется в подклассах
protected abstract String generateHeader();
protected abstract String generateBody();
protected abstract String generateFooter();
}

class SalesReport extends ReportGenerator {
public SalesReport() { super("Отчёт по продажам"); }

@Override protected String generateHeader() { return "Период: Q3 2025"; }
@Override protected String generateBody() { return "Итого: 1 250 000 ₽"; }
@Override protected String generateFooter() { return "Подпись: главбух"; }
}

Здесь применён шаблон Шаблонный метод (Template Method): алгоритм generate() фиксирован, но его шаги делегированы подклассам. Абстрактный класс позволяет:

  • Делиться кодом реализации между подклассами (поля, конструкторы, вспомогательные методы).
  • Задавать частично реализованный шаблон поведения.
  • Обеспечивать инварианты: например, title инициализируется в конструкторе и не может быть null.

Когда использовать абстрактный класс:

  • Когда есть реальное повторное использование кода (не только сигнатур).
  • Когда подклассы действительно находятся в отношении is-a.
  • Когда нужен доступ к protected полям/методам в иерархии.
  • Когда требуется конструктор с параметрами для инициализации общего состояния.

abstract

Абстрактный класс не может быть инстанциирован. Может содержать абстрактные методы (без реализации), которые обязаны быть реализованы в подклассах.

public abstract class Shape {
public abstract double area();

public void printArea() {
System.out.println("Area: " + area());
}
}

public class Circle extends Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double area() {
return Math.PI * radius * radius;
}
}

Интерфейсы: контракты без реализации (и с ней — с Java 8+)

Изначально (до Java 8) интерфейс мог содержать только:

  • Объявления public abstract методов (ключевые слова можно опускать).
  • public static final константы.

С Java 8 появились:

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

С Java 9 — private методы в интерфейсах (для рефакторинга дублирующегося кода внутри default-методов).

Пример:

public interface PaymentProcessor {
boolean processPayment(BigDecimal amount); // абстрактный метод

default boolean processRefund(BigDecimal amount) {
return processPayment(amount.negate());
}

static PaymentProcessor testProcessor() {
return amount -> amount.compareTo(BigDecimal.ZERO) > 0;
}
}

Другой пример - когда мы создаём рисуемый Drawable и непосредственно круг Circle:

public interface Drawable {
void draw();
}

Реализация:

public class Circle implements Drawable {
@Override
public void draw() {
System.out.println("Рисую круг");
}
}

Класс может реализовывать несколько интерфейсов.

public class SmartDevice implements Connectable, Updatable {
@Override
public void connect() { ... }

@Override
public void update() { ... }
}

Интерфейсы могут иметь методы с реализацией по умолчанию.

public interface Logger {
void log(String message);

default void logError(String message) {
log("ERROR: " + message);
}

static Logger createConsoleLogger() {
return System.out::println;
}
}

Это позволяет расширять интерфейсы без ломания существующих реализаций.

Интерфейсы предоставляют:

  • Множественную реализацию контрактов: класс может implements несколько интерфейсов.
  • Стабильный API: добавление default-метода в интерфейс не ломает существующие реализации (в отличие от добавления абстрактного метода в абстрактный класс).
  • Гибкость проектирования: зависимости можно строить на интерфейсах, а реализации подставлять через внедрение зависимостей (DI).

Однако интерфейсы:

  • Не могут содержать состояние экземпляра (только static final константы).
  • Не имеют конструкторов — инициализация должна происходить иначе.
  • default-методы не наследуются при конфликте имён (если два интерфейса имеют default-метод с одинаковой сигнатурой, реализующий класс обязан явно разрешить конфликт).

Когда использовать интерфейс, а когда абстрактный класс?

КритерийИнтерфейсАбстрактный класс
Количество наследниковМножественноеОдиночное
Общее состояниеНет (только константы)Да (protected/private поля)
Общая реализацияdefault/static методы (ограниченно)Любая (private, protected, конструкторы)
Изменяемость APIdefault-методы — безопасное расширениеДобавление абстрактного метода — ломающее изменение
Отношение«может делать» (can-do) или «имеет поведение»«является» (is-a)

На практике часто применяют гибридный подход: интерфейс задаёт контракт, абстрактный класс (часто с именем AbstractXxx) предоставляет частичную реализацию для удобства.

public interface Repository<T, ID> {
T findById(ID id);
List<T> findAll();
void save(T entity);
void delete(T entity);
}

public abstract class AbstractInMemoryRepository<T, ID> implements Repository<T, ID> {
protected final Map<ID, T> storage = new HashMap<>();

@Override
public T findById(ID id) {
return storage.get(id);
}

@Override
public List<T> findAll() {
return new ArrayList<>(storage.values());
}

// save и delete остаются абстрактными — логика зависит от структуры ID и equals/hashCode
}

Такой подход даёт гибкость: клиенты зависят от Repository, реализации могут быть InMemory, Jdbc, Mongo — и при этом InMemory реализации получают готовый каркас бесплатно.


Эволюция абстракции: запечатанные классы (sealed classes) — Java 17+

Начиная с Java 17, появился механизм запечатанных классов — способ явно ограничить, какие классы могут наследоваться от данного. Это дополняет абстракцию: теперь можно выразить ограниченную иерархию (например, «тип выражения в DSL может быть только Literal, BinaryOp или FunctionCall»).

public sealed interface Expr
permits Literal, BinaryOp, FunctionCall {}

final class Literal implements Expr {
private final int value;
public Literal(int value) { this.value = value; }
public int value() { return value; }
}

non-sealed class BinaryOp implements Expr {
private final String op;
private final Expr left, right;
// ...
}

// FunctionCall — final или non-sealed, но обязан быть упомянут в permits

Преимущества:

  • Компилятор может проверить исчерпаемость при использовании instanceof или switch (особенно с pattern matching).
  • Чёткое выражение замысла проектировщика: «другие реализации не предусмотрены».
  • Повышение безопасности: нельзя внедрить стороннюю реализацию в закрытую доменную модель.

Это дополнение — инструмент для случаев, когда иерархия по смыслу конечна и контролируема.


Объединение принципов

Четыре принципа ООП — взаимосвязанные слои архитектуры:

  • Абстракция задаёт границы модели — что важно, а что можно скрыть.
  • Инкапсуляция защищает внутреннее состояние и гарантирует, что абстракция остаётся согласованной.
  • Наследование (или реализация интерфейсов) позволяет строить иерархии абстракций и повторно использовать код.
  • Полиморфизм позволяет писать код, ориентированный на абстракции, а не на конкретики — повышая гибкость и тестируемость.

Пример синтеза:

// Абстракция: контракт
public interface Logger {
void log(LogLevel level, String message);
}

// Абстрактный класс: частичная реализация + инкапсуляция
public abstract class BufferedLogger implements Logger {
private final Queue<String> buffer = new LinkedList<>();
private final int bufferSize;

protected BufferedLogger(int bufferSize) {
this.bufferSize = bufferSize;
}

@Override
public final void log(LogLevel level, String message) {
String entry = format(level, message);
buffer.add(entry);
if (buffer.size() >= bufferSize) {
flush();
}
}

protected abstract String format(LogLevel level, String message);
protected abstract void writeBatch(List<String> batch);

private void flush() {
List<String> batch = new ArrayList<>(buffer);
buffer.clear();
writeBatch(batch); // делегирование — инкапсуляция внутреннего буфера сохраняется
}
}

// Конкретная реализация — полиморфизм в действии
public class FileLogger extends BufferedLogger {
private final PrintWriter writer;

public FileLogger(int bufferSize, PrintWriter writer) {
super(bufferSize);
this.writer = writer;
}

@Override protected String format(LogLevel level, String message) {
return Instant.now() + " [" + level + "] " + message;
}

@Override protected void writeBatch(List<String> batch) {
batch.forEach(writer::println);
writer.flush();
}
}

Клиентский код:

Logger logger = new FileLogger(10, new PrintWriter("app.log"));
logger.log(LogLevel.INFO, "Система запущена"); // ← полиморфный вызов
// Можно легко заменить на DatabaseLogger, NetworkLogger — без изменения клиентского кода

Здесь:

  • Logger — абстракция.
  • BufferedLogger — инкапсулирует буфер и алгоритм сброса, предоставляет шаблон.
  • FileLogger — наследует и специализирует.
  • Клиент — зависит от абстракции, использует полиморфизм.

Это и есть сила ООП в Java: в возможности строить понятные, проверяемые, расширяемые модели реального мира.


Класс Object: фундаментальная база и её последствия

В Java любой класс, явно или неявно, наследуется от java.lang.Object. Это значит, что каждый объект обладает набором базовых методов, определённых в Object. От корректной реализации некоторых из них зависит логика приложения и корректность работы стандартных библиотек (HashMap, HashSet, ArrayList, потоки, сериализация и др.).

equals(Object obj) и hashCode(): контракт, а не опция

Метод equals() по умолчанию реализует сравнение по ссылке (==), что для большинства пользовательских классов не соответствует семантике равенства. При переопределении equals() обязательно должен быть переопределён и hashCode(), иначе нарушается фундаментальный контракт:

Если a.equals(b) == true, то a.hashCode() == b.hashCode() должно быть истинно.

Нарушение этого контракта приводит к тому, что объекты, которые логически равны, могут быть помещены в разные корзины хеш-таблицы и стать «невидимыми» для поиска.

Правила реализации equals()
  1. Рефлексивность: x.equals(x) должно быть true.
  2. Симметричность: x.equals(y) ⇔ y.equals(x).
  3. Транзитивность: если x.equals(y) и y.equals(z), то x.equals(z).
  4. Согласованность: многократные вызовы x.equals(y) должны давать один и тот же результат, пока ничего не изменилось.
  5. Неравенство с null: x.equals(null) должно быть false.

Типичная реализация — через последовательную проверку:

@Override
public boolean equals(Object obj) {
if (this == obj) return true; // оптимизация: сравнение ссылок
if (obj == null || getClass() != obj.getClass()) return false; // null и разные классы — не равны

Person other = (Person) obj;
return Objects.equals(name, other.name) &&
Objects.equals(birthDate, other.birthDate);
}

Важные замечания:

  • Использование getClass() != obj.getClass() (а не instanceof) гарантирует симметричность при наследовании. Если допустить подклассы через instanceof, возможна ситуация: person.equals(student) == true, но student.equals(person) == false, если Student добавляет новые поля в сравнение.
  • Objects.equals() безопасно обрабатывает null для полей.
  • Поля, используемые в equals(), должны быть неизменяемыми или изменяться крайне осторожно — иначе объект может «потеряться» в HashSet.
hashCode(): простота, скорость, стабильность

Хеш-код должен:

  • Быть одинаковым для объектов, для которых equals() возвращает true.
  • Иметь хорошее распределение (минимизировать коллизии).
  • Вычисляться быстро.
  • Не меняться при вызовах (если объект используется в хеш-коллекциях).

Рекомендуемый способ (начиная с Java 7):

@Override
public int hashCode() {
return Objects.hash(name, birthDate);
}

Для критичных по производительности сценариев можно использовать ручной расчёт (например, через 31 * result + field.hashCode()), но Objects.hash() — надёжный и читаемый выбор.

toString()

По умолчанию toString() возвращает что-то вроде com.example.Person@1f32e57. Это бесполезно. Переопределение toString() — это инвестиция в диагностику.

Хороший toString() должен:

  • Содержать имя класса и ключевые идентификаторы (например, id, name).
  • Не включать чувствительные данные (пароли, токены).
  • Не вызывать побочных эффектов (например, lazy-загрузку связанных сущностей).
  • Быть согласованным с equals() — поля, участвующие в равенстве, должны присутствовать.
@Override
public String toString() {
return "Person[id=" + id + ", name=" + name + "]";
}

Современный Java (14+) позволяет использовать text blocks и шаблонные строки (в preview-фичах), но даже простой конкатенации достаточно — главное, чтобы вывод был однозначно интерпретируем.

clone() — антипаттерн, которого следует избегать

Метод clone() в Object объявлен как protected native, а его контракт требует реализации интерфейса Cloneable — маркерного интерфейса без методов. Это приводит к:

  • Ненадёжной реализации (глубокое/поверхностное копирование — неочевидно).
  • Проблемам с наследованием (подкласс должен знать, как клонировать родительскую часть).
  • Нарушению инкапсуляции (доступ к protected clone() извне требует публичного обёрточного метода).

Рекомендация: не используйте clone(). Вместо этого:

  • Для неизменяемых объектов — клонирование не нужно.
  • Для изменяемых — реализуйте копирующий конструктор или фабричный метод:
public class Person {
private final String name;
private final LocalDate birthDate;

public Person(String name, LocalDate birthDate) {
this.name = name;
this.birthDate = birthDate;
}

// Копирующий конструктор
public Person(Person original) {
this(original.name, original.birthDate);
}

// Фабричный метод — более гибкий
public static Person copyOf(Person original) {
return new Person(original.name, original.birthDate);
}
}

Это явно, безопасно, типизировано и не требует Cloneable.


Записи (Records)

С Java 16 появился новый вид класса — record. Это спецификация неизменяемой структуры данных, где семантика равенства, хеш и строковое представление выводятся из описания состояния.

record — специальный тип класса для хранения неизменяемых данных. Автоматически генерирует конструктор, геттеры, equals, hashCode, toString.

public record Point(int x, int y) {}

Использование:

Point p = new Point(10, 20);
System.out.println(p.x()); // 10
System.out.println(p.y()); // 20

Records идеальны для DTO, ключей мап и простых структур данных.

Компилятор автоматически генерирует:

  • Приватное final поле для каждого компонента.
  • Публичный конструктор (с проверкой Objects.requireNonNull для ссылочных типов).
  • Геттеры с именами компонентов (x(), y()).
  • equals(), hashCode(), toString() — на основе компонентов.

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

Когда использовать record:

  • Для DTO, value objects, ключей, событий, параметров команд.
  • Когда класс состоит только из private final полей и «очевидных» методов (equals, hashCode, toString, геттеры).
  • Когда семантика структурного равенства (по содержимому) соответствует предметной области.

Когда не использовать:

  • Если нужна инкапсуляция внутреннего состояния (например, валидация в сеттере — но сеттеров в record нет).
  • Если требуется наследование или реализация интерфейсов с нестандартным поведением (хотя implements разрешён, но с осторожностью).
  • Если объект должен иметь изменяемое состояние — record по определению неизменяем.

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


SOLID

ООП в Java особенно мощен в сочетании с принципами проектирования. Ниже — краткое погружение в то, как ключевые идеи SOLID проявляются в коде.

S — Single Responsibility Principle (Принцип единственной ответственности)

Класс должен иметь одну, и только одну, причину для изменения. В контексте Java это означает:

  • Разделение данных, поведения и представления.
  • Избегание «божественных классов» (UserService, который работает с БД, отправляет email, логирует, валидирует, конвертирует в JSON).
  • Использование композиции: UserServiceUserRepository, EmailSender, AuditLogger.

ООП-механизмы, поддерживающие SRP:

  • Интерфейсы — позволяют выделить отдельные роли (UserRepository, UserNotificationService).
  • Инкапсуляция — предотвращает «растекание» логики по полям.

O — Open/Closed Principle (Принцип открытости/закрытости)

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

  • Абстракции (interface, abstract class).
  • Полиморфизм — добавление нового поведения через новый класс, а не изменение существующего.
  • Strategy, Template Method, Factory — паттерны, построенные на этом принципе.

Пример:

// Закрыт для модификации: интерфейс не меняется
public interface DiscountStrategy {
BigDecimal apply(BigDecimal price);
}

// Открыт для расширения: новые стратегии — без правки старого кода
public class TenPercentDiscount implements DiscountStrategy { ... }
public class VolumeDiscount implements DiscountStrategy { ... }

L — Liskov Substitution Principle (Принцип подстановки)

Уже обсуждался, но подчеркнём: это про сохранение контракта. Если подкласс ломает ожидания вызывающего кода — это ошибка проектирования, даже если компилятор молчит.

Инструменты контроля:

  • Unit-тесты для суперкласса должны проходить и для подклассов.
  • Анализ @Override-методов: не ужесточаются ли предусловия? не ослабляются ли постусловия?
  • Использование композиции вместо наследования при сомнениях.

I — Interface Segregation Principle (Принцип разделения интерфейсов)

Клиенты не должны зависеть от методов, которыми они не пользуются. В Java — это борьба с «толстыми» интерфейсами (UserService с 20 методами).

Решения:

  • Дробление интерфейсов по ролям: UserReader, UserWriter, UserNotifier.
  • Использование default-методов осторожно: они могут замаскировать нарушение ISP.
  • Предпочтение узкоспециализированных интерфейсов (Runnable, Callable, AutoCloseable) общим.

D — Dependency Inversion Principle (Принцип инверсии зависимостей)

Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

В Java это означает:

  • Зависимости объявляются через интерфейсы (private final UserRepository repository).
  • Конкретные реализации подставляются извне (через конструктор — dependency injection).
  • Фреймворки (Spring, Guice) — инструменты, а не цель; суть — в архитектуре.
public class OrderService {
private final PaymentProcessor paymentProcessor;

// Зависимость инвертирована: класс зависит от интерфейса, а не от PayPalProcessor
public OrderService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
}

Типичные архитектурные ловушки и как их избегать

ЛовушкаПричинаРешение
Анемичная модельПоля public, все операции — в «сервисах»: user.setName(...), userService.validate(user)Перенос поведения в объект: user.changeName(newName), user.validate(). Объект управляет своим состоянием.
Избыточное наследование«Для доступа к полям» или «потому что можно»Замена наследования композицией: Car содержит Engine, а не наследуется от Engine.
Неконтролируемая иерархияГлубокие цепочки наследования (A → B → C → D → E)Ограничение глубины (рекомендуется ≤ 3), использование интерфейсов и делегирования.
Нарушение инкапсуляции через сеттерыsetXxx() для всех полей — даже там, где логика изменения сложнаУдаление сеттеров. Предоставление методов с доменной семантикой: account.transferTo(other, amount).
Перегрузка equals() без hashCode()Забвение контрактаИспользование IDE-генерации или Objects.hash(); unit-тесты на контракт.
Избыточные интерфейсы «на будущее»interface IUserService extends UserService без причинПрименение интерфейсов только при наличии реальной необходимости (моки, множественная реализация, DI).

Пример простейшей игры на Java

package com.test;

import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class BattleGame extends JFrame {
private Unit player;
private Unit enemy;
private Random random = new Random();

private JLabel playerHealthLabel;
private JLabel enemyHealthLabel;
private JLabel playerManaLabel;
private JLabel enemyManaLabel;
private JLabel playerExpLabel;
private JLabel enemyNameLabel;

private JTextArea battleLog;
private JPanel abilitiesPanel;

public BattleGame() {
setTitle("Битва героев: Фэнтези РПГ");
setSize(850, 650);
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setLayout(new BorderLayout());

player = createUnitByClass("Воин", Unit.ClassType.WARRIOR);
enemy = createUnitByClass("Тёмный Маг", Unit.ClassType.MAGE);

JPanel statsPanel = new JPanel(new GridLayout(1, 2, 20, 10));
statsPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
statsPanel.add(createPlayerStatsPanel());
statsPanel.add(createEnemyStatsPanel());
add(statsPanel, BorderLayout.NORTH);

battleLog = new JTextArea();
battleLog.setEditable(false);
battleLog.setFont(new Font("Monospaced", Font.PLAIN, 13));
battleLog.setBackground(new Color(240, 240, 240));
JScrollPane logScrollPane = new JScrollPane(battleLog);
logScrollPane.setBorder(BorderFactory.createTitledBorder("Хроники битвы"));
add(logScrollPane, BorderLayout.CENTER);

JPanel controlPanel = new JPanel(new BorderLayout(10, 10));
controlPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));

abilitiesPanel = new JPanel();
abilitiesPanel.setLayout(new FlowLayout(FlowLayout.LEFT));
updateAbilitiesPanel();
controlPanel.add(abilitiesPanel, BorderLayout.CENTER);

JPanel buttonPanel = new JPanel();
JButton newBattleBtn = new JButton("Новая битва");
newBattleBtn.addActionListener(e -> startNewBattle());
buttonPanel.add(newBattleBtn);

controlPanel.add(buttonPanel, BorderLayout.SOUTH);
add(controlPanel, BorderLayout.SOUTH);

logMessage("⚔️ Битва началась! " + player.name + " (" + player.classType.getName() + ") против " + enemy.name);
logMessage("💡 Совет: Используйте способности для тактического преимущества!");
}

private JPanel createPlayerStatsPanel() {
JPanel panel = new JPanel(new BorderLayout());
panel.setBorder(BorderFactory.createTitledBorder("Игрок: " + player.name + " (" + player.classType.getName() + ")"));

JPanel statsGrid = new JPanel(new GridLayout(5, 2, 5, 5));
statsGrid.add(new JLabel("Уровень:"));
statsGrid.add(new JLabel(String.valueOf(player.level)));

statsGrid.add(new JLabel("Здоровье:"));
playerHealthLabel = new JLabel(player.health + "/" + player.maxHealth);
statsGrid.add(playerHealthLabel);

statsGrid.add(new JLabel("Мана:"));
playerManaLabel = new JLabel(player.mana + "/" + player.maxMana);
statsGrid.add(playerManaLabel);

statsGrid.add(new JLabel("Опыт:"));
playerExpLabel = new JLabel(player.experience + "/" + player.nextLevelExp);
statsGrid.add(playerExpLabel);

statsGrid.add(new JLabel("Экипировка:"));
statsGrid.add(new JLabel(getEquipmentString(player)));

panel.add(statsGrid, BorderLayout.CENTER);
return panel;
}

private JPanel createEnemyStatsPanel() {
JPanel panel = new JPanel(new BorderLayout());
panel.setBorder(BorderFactory.createTitledBorder("Противник"));

JPanel statsGrid = new JPanel(new GridLayout(4, 2, 5, 5));
enemyNameLabel = new JLabel(enemy.name + " (" + enemy.classType.getName() + ")");
statsGrid.add(new JLabel("Имя:"));
statsGrid.add(enemyNameLabel);

statsGrid.add(new JLabel("Здоровье:"));
enemyHealthLabel = new JLabel(enemy.health + "/" + enemy.maxHealth);
statsGrid.add(enemyHealthLabel);

statsGrid.add(new JLabel("Мана:"));
enemyManaLabel = new JLabel(enemy.mana + "/" + enemy.maxMana);
statsGrid.add(enemyManaLabel);

statsGrid.add(new JLabel("Экипировка:"));
statsGrid.add(new JLabel(getEquipmentString(enemy)));

panel.add(statsGrid, BorderLayout.CENTER);
return panel;
}

private String getEquipmentString(Unit unit) {
String weapon = unit.weapon != null ? unit.weapon.name : "—";
String armor = unit.armor != null ? unit.armor.name : "—";
return "Оружие: " + weapon + " | Доспехи: " + armor;
}

private void updateAbilitiesPanel() {
abilitiesPanel.removeAll();

for (Ability ability : player.abilities) {
JButton btn = new JButton(ability.name + " (" + ability.manaCost + " MP)");
btn.setEnabled(player.mana >= ability.manaCost && player.health > 0 && enemy.health > 0);
final Ability selectedAbility = ability;
btn.addActionListener(e -> executePlayerTurn(selectedAbility));
abilitiesPanel.add(btn);
}

JButton basicAttackBtn = new JButton("Базовая атака");
basicAttackBtn.setEnabled(player.health > 0 && enemy.health > 0);
basicAttackBtn.addActionListener(e -> executePlayerTurn(null));
abilitiesPanel.add(basicAttackBtn);

abilitiesPanel.revalidate();
abilitiesPanel.repaint();
}

private void executePlayerTurn(Ability ability) {
if (player.health <= 0 || enemy.health <= 0) return;

int damage = ability != null ? calculateAbilityDamage(player, enemy, ability) : calculatePhysicalDamage(player, enemy);
String actionName = ability != null ? ability.name : "базовая атака";

if (calculateDodgeChance(enemy)) {
logMessage(String.format("💨 %s уклонился от %s %s!", enemy.name, actionName, player.name));
} else {
if (calculateBlockChance(enemy)) {
int blocked = (int)(damage * 0.6);
damage -= blocked;
logMessage(String.format("🛡️ %s заблокировал %d урона!", enemy.name, blocked));
}
enemy.health = Math.max(0, enemy.health - damage);
logMessage(String.format("💥 %s нанёс %d урона %s (%s)", player.name, damage, enemy.name, actionName));
}

if (ability != null) {
player.mana -= ability.manaCost;
}

updateUI();

if (enemy.health <= 0) {
enemy.health = 0;
int expGain = 50 + enemy.level * 25;
player.gainExperience(expGain);
logMessage(String.format("🎉 %s победил! Получено %d опыта", player.name, expGain));
if (player.checkLevelUp()) {
logMessage(String.format("✨ %s достиг %d уровня! Характеристики улучшены.", player.name, player.level));
}
disableControls();
return;
}

Timer timer = new Timer(800, e -> {
executeEnemyTurn();
updateUI();
});
timer.setRepeats(false);
timer.start();
}

private void executeEnemyTurn() {
if (player.health <= 0 || enemy.health <= 0) return;

Ability enemyAbility = null;
if (enemy.classType == Unit.ClassType.MAGE && enemy.mana >= 20 && random.nextInt(100) < 60) {
enemyAbility = enemy.abilities.get(0);
}

int damage = enemyAbility != null ? calculateAbilityDamage(enemy, player, enemyAbility) : calculatePhysicalDamage(enemy, player);
String actionName = enemyAbility != null ? enemyAbility.name : "базовая атака";

if (calculateDodgeChance(player)) {
logMessage(String.format("💨 %s уклонился от %s %s!", player.name, actionName, enemy.name));
} else {
if (calculateBlockChance(player)) {
int blocked = (int)(damage * 0.6);
damage -= blocked;
logMessage(String.format("🛡️ %s заблокировал %d урона!", player.name, blocked));
}
player.health = Math.max(0, player.health - damage);
logMessage(String.format("💥 %s нанёс %d урона %s (%s)", enemy.name, damage, player.name, actionName));
}

if (enemyAbility != null) {
enemy.mana -= enemyAbility.manaCost;
}

if (player.health <= 0) {
player.health = 0;
logMessage(String.format("☠️ %s пал в бою...", player.name));
disableControls();
}
}

private int calculatePhysicalDamage(Unit attacker, Unit defender) {
int baseDamage = attacker.strength;
if (attacker.weapon != null) baseDamage += attacker.weapon.damageBonus;
int reduction = defender.armor != null ? defender.armor.damageReduction : 0;
int finalDamage = (int)(baseDamage * (1 - reduction / 100.0)) + attacker.level;
return Math.max(1, finalDamage); // Минимум 1 урона
}

private int calculateAbilityDamage(Unit caster, Unit target, Ability ability) {
if (ability.type == Ability.Type.HEAL) {
return Math.min(ability.power, caster.maxHealth - caster.health);
}
return caster.intel * 2 + ability.power + caster.level;
}

private boolean calculateDodgeChance(Unit unit) {
int chance = Math.min(50, unit.agility / 2);
return random.nextInt(100) < chance;
}

private boolean calculateBlockChance(Unit unit) {
int chance = Math.min(40, (unit.strength + unit.agility) / 4);
return random.nextInt(100) < chance;
}

private void updateUI() {
playerHealthLabel.setText(player.health + "/" + player.maxHealth);
enemyHealthLabel.setText(enemy.health + "/" + enemy.maxHealth);
playerManaLabel.setText(player.mana + "/" + player.maxMana);
enemyManaLabel.setText(enemy.mana + "/" + enemy.maxMana);
playerExpLabel.setText(player.experience + "/" + player.nextLevelExp);
enemyNameLabel.setText(enemy.name + " (" + enemy.classType.getName() + ")");
updateAbilitiesPanel();
}

private void disableControls() {
for (Component c : abilitiesPanel.getComponents()) {
if (c instanceof JButton) ((JButton) c).setEnabled(false);
}
}

private void startNewBattle() {
Unit.ClassType[] classes = Unit.ClassType.values();
Unit.ClassType playerClass = classes[random.nextInt(classes.length)];
Unit.ClassType enemyClass = classes[random.nextInt(classes.length)];

player = createUnitByClass("Герой", playerClass);
enemy = createUnitByClass("Враг", enemyClass);

battleLog.setText("");
logMessage(String.format("⚔️ Новая битва! %s (%s) против %s (%s)",
player.name, player.classType.getName(),
enemy.name, enemy.classType.getName()));

updateUI();
for (Component c : abilitiesPanel.getComponents()) {
if (c instanceof JButton) ((JButton) c).setEnabled(true);
}
}

private Unit createUnitByClass(String name, Unit.ClassType type) {
Unit unit = new Unit(name, type);

switch (type) {
case WARRIOR:
unit.equipWeapon(new Weapon("Стальной меч", 10));
unit.equipArmor(new Armor("Латы", 20));
break;
case MAGE:
unit.equipWeapon(new Weapon("Волшебный посох", 4));
unit.equipArmor(new Armor("Магическая роба", 8));
break;
case ARCHER:
unit.equipWeapon(new Weapon("Композитный лук", 8));
unit.equipArmor(new Armor("Кожаный доспех", 12));
break;
case ASSASSIN:
unit.equipWeapon(new Weapon("Серебряные кинжалы", 7));
unit.equipArmor(new Armor("Теневая мантия", 10));
break;
}

return unit;
}

private void logMessage(String message) {
battleLog.append(message + "\n");
battleLog.setCaretPosition(battleLog.getDocument().getLength());
}

public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
BattleGame game = new BattleGame();
game.setVisible(true);
});
}

// ========== ВНУТРЕННИЕ КЛАССЫ ==========
static class Unit {
enum ClassType {
WARRIOR("Воин"), MAGE("Маг"), ARCHER("Лучник"), ASSASSIN("Ассасин");
private final String name;
ClassType(String name) { this.name = name; }
public String getName() { return name; }
}

String name;
ClassType classType;
int intel, agility, strength;
int health, maxHealth;
int mana, maxMana;
int level;
int experience, nextLevelExp;
Weapon weapon;
Armor armor;
List<Ability> abilities = new ArrayList<>();

public Unit(String name, ClassType classType) {
this.name = name;
this.classType = classType;
this.level = 1;
this.experience = 0;
this.nextLevelExp = 100;

switch (classType) {
case WARRIOR:
this.strength = 28; this.agility = 14; this.intel = 8;
this.maxHealth = 130; this.maxMana = 45;
this.abilities.add(new Ability("Щитовой удар", Ability.Type.DAMAGE, 18, 20));
break;
case MAGE:
this.strength = 7; this.agility = 12; this.intel = 32;
this.maxHealth = 85; this.maxMana = 110;
this.abilities.add(new Ability("Фаербол", Ability.Type.DAMAGE, 28, 25));
this.abilities.add(new Ability("Исцеление", Ability.Type.HEAL, 25, 35));
break;
case ARCHER:
this.strength = 14; this.agility = 30; this.intel = 11;
this.maxHealth = 100; this.maxMana = 65;
this.abilities.add(new Ability("Прицельный выстрел", Ability.Type.DAMAGE, 24, 22));
break;
case ASSASSIN:
this.strength = 20; this.agility = 35; this.intel = 13;
this.maxHealth = 95; this.maxMana = 55;
this.abilities.add(new Ability("Рывок из тени", Ability.Type.DAMAGE, 26, 28));
break;
}

this.health = maxHealth;
this.mana = maxMana;
}

public void equipWeapon(Weapon weapon) { this.weapon = weapon; }
public void equipArmor(Armor armor) { this.armor = armor; }

public void gainExperience(int amount) { this.experience += amount; }

public boolean checkLevelUp() {
if (this.experience < this.nextLevelExp) return false;

this.level++;
this.strength += 2 + (classType == ClassType.WARRIOR ? 2 : 0);
this.agility += 2 + (classType == ClassType.ASSASSIN || classType == ClassType.ARCHER ? 2 : 0);
this.intel += 2 + (classType == ClassType.MAGE ? 3 : 0);
this.maxHealth += 18;
this.maxMana += 12;
this.health = this.maxHealth;
this.mana = this.maxMana;
this.experience -= this.nextLevelExp;
this.nextLevelExp = (int)(this.nextLevelExp * 1.6);

return true;
}
}

static class Weapon {
String name; int damageBonus;
Weapon(String name, int damageBonus) { this.name = name; this.damageBonus = damageBonus; }
}

static class Armor {
String name; int damageReduction;
Armor(String name, int damageReduction) { this.name = name; this.damageReduction = damageReduction; }
}

static class Ability {
enum Type { DAMAGE, HEAL }
String name; Type type; int power; int manaCost;
Ability(String name, Type type, int power, int manaCost) {
this.name = name; this.type = type; this.power = power; this.manaCost = manaCost;
}
}
}

Ещё один пример

Разберём ещё один пример - неплохой Space Invaders:

package com.test;

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class SpaceInvaders extends JPanel implements ActionListener, KeyListener {
private static final int BOARD_WIDTH = 800;
private static final int BOARD_HEIGHT = 600;
private static final int PLAYER_WIDTH = 40;
private static final int PLAYER_HEIGHT = 20;
private static final int ENEMY_ROWS = 5;
private static final int ENEMY_COLS = 10;
private static final int ENEMY_WIDTH = 30;
private static final int ENEMY_HEIGHT = 20;
private static final int BULLET_SIZE = 4;

private Timer timer;
private Player player;
private List<Enemy> enemies;
private List<Bullet> bullets;
private boolean[] keys = new boolean[256];
private int score = 0;
private int lives = 3;
private boolean gameOver = false;
private boolean gameWon = false;
private int enemyDirection = 1; // 1 — right, -1 — left
private int enemyStepDown = 0;

public SpaceInvaders() {
setPreferredSize(new Dimension(BOARD_WIDTH, BOARD_HEIGHT));
setBackground(Color.BLACK);
setFocusable(true);
addKeyListener(this);

initGame();
timer = new Timer(20, this); // ~50 FPS
timer.start();
}

private void initGame() {
player = new Player(BOARD_WIDTH / 2 - PLAYER_WIDTH / 2, BOARD_HEIGHT - 40);
enemies = new ArrayList<>();
bullets = new ArrayList<>();

for (int row = 0; row < ENEMY_ROWS; row++) {
for (int col = 0; col < ENEMY_COLS; col++) {
int x = 100 + col * (ENEMY_WIDTH + 10);
int y = 50 + row * (ENEMY_HEIGHT + 10);
enemies.add(new Enemy(x, y));
}
}

score = 0;
lives = 3;
gameOver = false;
gameWon = false;
enemyDirection = 1;
enemyStepDown = 0;
}

@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
if (gameOver) {
drawGameOver(g);
} else if (gameWon) {
drawGameWon(g);
} else {
drawPlayer(g);
drawEnemies(g);
drawBullets(g);
drawHUD(g);
}
}

private void drawPlayer(Graphics g) {
g.setColor(Color.GREEN);
g.fillRect(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT);
}

private void drawEnemies(Graphics g) {
g.setColor(Color.RED);
for (Enemy e : enemies) {
g.fillRect(e.x, e.y, ENEMY_WIDTH, ENEMY_HEIGHT);
}
}

private void drawBullets(Graphics g) {
g.setColor(Color.WHITE);
for (Bullet b : bullets) {
g.fillRect(b.x, b.y, BULLET_SIZE, BULLET_SIZE);
}
}

private void drawHUD(Graphics g) {
g.setColor(Color.WHITE);
g.setFont(new Font("Arial", Font.PLAIN, 16));
g.drawString("Score: " + score, 10, 20);
g.drawString("Lives: " + lives, BOARD_WIDTH - 100, 20);
}

private void drawGameOver(Graphics g) {
g.setColor(Color.WHITE);
g.setFont(new Font("Arial", Font.BOLD, 40));
FontMetrics fm = g.getFontMetrics();
String text = "GAME OVER";
int x = (BOARD_WIDTH - fm.stringWidth(text)) / 2;
int y = BOARD_HEIGHT / 2;
g.drawString(text, x, y);
g.setFont(new Font("Arial", Font.PLAIN, 20));
g.drawString("Press R to restart", x + 80, y + 40);
}

private void drawGameWon(Graphics g) {
g.setColor(Color.CYAN);
g.setFont(new Font("Arial", Font.BOLD, 40));
FontMetrics fm = g.getFontMetrics();
String text = "YOU WIN!";
int x = (BOARD_WIDTH - fm.stringWidth(text)) / 2;
int y = BOARD_HEIGHT / 2;
g.drawString(text, x, y);
g.setFont(new Font("Arial", Font.PLAIN, 20));
g.drawString("Press R to restart", x + 70, y + 40);
}

@Override
public void actionPerformed(ActionEvent e) {
if (!gameOver && !gameWon) {
updatePlayer();
updateBullets();
updateEnemies();
checkCollisions();
checkWinOrLose();
}
repaint();
}

private void updatePlayer() {
if (keys[KeyEvent.VK_LEFT] && player.x > 0) {
player.x -= 5;
}
if (keys[KeyEvent.VK_RIGHT] && player.x < BOARD_WIDTH - PLAYER_WIDTH) {
player.x += 5;
}
if (keys[KeyEvent.VK_SPACE]) {
shoot();
keys[KeyEvent.VK_SPACE] = false;
}
}

private void shoot() {
bullets.add(new Bullet(player.x + PLAYER_WIDTH / 2 - BULLET_SIZE / 2, player.y));
}

private void updateBullets() {
bullets.removeIf(b -> b.y < 0);
for (Bullet b : bullets) {
b.y -= 7;
}
}

private void updateEnemies() {
boolean moveDown = false;
for (Enemy e : enemies) {
if ((enemyDirection == 1 && e.x + ENEMY_WIDTH >= BOARD_WIDTH - 10) ||
(enemyDirection == -1 && e.x <= 10)) {
moveDown = true;
break;
}
}

if (moveDown) {
enemyDirection *= -1;
for (Enemy e : enemies) {
e.y += 20;
}
} else {
for (Enemy e : enemies) {
e.x += 2 * enemyDirection;
}
}
}

private void checkCollisions() {
List<Bullet> bulletsToRemove = new ArrayList<>();
List<Enemy> enemiesToRemove = new ArrayList<>();

for (Bullet b : bullets) {
for (Enemy e : enemies) {
if (b.x < e.x + ENEMY_WIDTH &&
b.x + BULLET_SIZE > e.x &&
b.y < e.y + ENEMY_HEIGHT &&
b.y + BULLET_SIZE > e.y) {
bulletsToRemove.add(b);
enemiesToRemove.add(e);
score += 10;
break;
}
}
}

bullets.removeAll(bulletsToRemove);
enemies.removeAll(enemiesToRemove);

for (Enemy e : enemies) {
if (e.y + ENEMY_HEIGHT >= player.y) {
lives = 0;
break;
}
}
}

private void checkWinOrLose() {
if (enemies.isEmpty()) {
gameWon = true;
}
if (lives <= 0) {
gameOver = true;
}
}

@Override
public void keyPressed(KeyEvent e) {
keys[e.getKeyCode()] = true;
if (e.getKeyCode() == KeyEvent.VK_R && (gameOver || gameWon)) {
initGame();
}
}

@Override
public void keyReleased(KeyEvent e) {
if (e.getKeyCode() != KeyEvent.VK_SPACE) {
keys[e.getKeyCode()] = false;
}
}

@Override
public void keyTyped(KeyEvent e) {}

private static class Player {
int x, y;
Player(int x, int y) {
this.x = x;
this.y = y;
}
}

private static class Enemy {
int x, y;
Enemy(int x, int y) {
this.x = x;
this.y = y;
}
}

private static class Bullet {
int x, y;
Bullet(int x, int y) {
this.x = x;
this.y = y;
}
}

public static void main(String[] args) {
JFrame frame = new JFrame("Space Invaders");
SpaceInvaders game = new SpaceInvaders();
frame.add(game);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setResizable(false);
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
}

Пример - игра "Три в ряд"

package com.test;

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.*;
import java.util.*;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;

public class Match3Game extends JPanel {
// Размеры игрового поля
private static final int ROWS = 8;
private static final int COLS = 8;
private static final int TILE_SIZE = 70;
private static final int TYPES_COUNT = 7;

// Цвета фишек с градиентом
private static final Color[] COLORS = {
new Color(255, 100, 100), // Красный
new Color(100, 150, 255), // Синий
new Color(100, 255, 100), // Зеленый
new Color(255, 255, 100), // Желтый
new Color(255, 150, 100), // Оранжевый
new Color(200, 100, 255), // Фиолетовый
new Color(100, 255, 200) // Бирюзовый
};

private int[][] board;
private int score;
private int combo;
private int selectedX = -1, selectedY = -1;
private boolean isAnimating = false;
private List<Animation> animations;
private List<Particle> particles;
private javax.swing.Timer gameTimer;
private float timeOfDay = 0;

// Эффекты
private static class Animation {
int x, y;
float progress;
int type; // 0 - падение, 1 - исчезновение, 2 - замена
int startValue, endValue;

Animation(int x, int y, int type) {
this.x = x;
this.y = y;
this.type = type;
this.progress = 0;
}
}

private static class Particle {
float x, y;
float vx, vy;
int life;
Color color;

Particle(float x, float y, Color color) {
this.x = x;
this.y = y;
this.vx = (float)(Math.random() - 0.5) * 8;
this.vy = (float)(Math.random() - 0.5) * 8 - 5;
this.life = 30;
this.color = color;
}

void update() {
x += vx;
y += vy;
vy += 0.3;
life--;
}
}

public Match3Game() {
setPreferredSize(new Dimension(COLS * TILE_SIZE, ROWS * TILE_SIZE + 80));
setBackground(new Color(20, 30, 45));

board = new int[ROWS][COLS];
score = 0;
combo = 0;
animations = new ArrayList<>();
particles = new ArrayList<>();

initBoard();
removeAllMatches();

// Обработка мыши с визуальной обратной связью
addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (!isAnimating) {
int x = e.getX() / TILE_SIZE;
int y = e.getY() / TILE_SIZE;
if (x >= 0 && x < COLS && y >= 0 && y < ROWS) {
if (selectedX == -1) {
selectedX = x;
selectedY = y;
repaint();
} else {
trySwap(selectedX, selectedY, x, y);
selectedX = -1;
selectedY = -1;
}
} else {
selectedX = -1;
selectedY = -1;
repaint();
}
}
}
});

// Таймер для анимации и эффектов
gameTimer = new javax.swing.Timer(16, e -> {
updateAnimations();
updateParticles();
timeOfDay += 0.02;
repaint();
});
gameTimer.start();
}

private void initBoard() {
Random rand = new Random();
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
board[i][j] = rand.nextInt(TYPES_COUNT);
}
}
}

private void removeAllMatches() {
boolean hasMatches;
do {
hasMatches = false;
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
if (isMatchAt(i, j)) {
board[i][j] = (board[i][j] + 1) % TYPES_COUNT;
hasMatches = true;
}
}
}
} while (hasMatches);
}

private boolean isMatchAt(int row, int col) {
int value = board[row][col];
if (value == -1) return false;

int count = 1;
for (int j = col - 1; j >= 0 && board[row][j] == value; j--) count++;
for (int j = col + 1; j < COLS && board[row][j] == value; j++) count++;
if (count >= 3) return true;

count = 1;
for (int i = row - 1; i >= 0 && board[i][col] == value; i--) count++;
for (int i = row + 1; i < ROWS && board[i][col] == value; i++) count++;
return count >= 3;
}

private List<Point> findAllMatches() {
List<Point> matches = new ArrayList<>();
boolean[][] marked = new boolean[ROWS][COLS];

for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
int value = board[i][j];
if (value == -1) continue;

int hCount = 1;
for (int k = j + 1; k < COLS && board[i][k] == value; k++) hCount++;
if (hCount >= 3) {
for (int k = 0; k < hCount; k++) {
marked[i][j + k] = true;
}
}

int vCount = 1;
for (int k = i + 1; k < ROWS && board[k][j] == value; k++) vCount++;
if (vCount >= 3) {
for (int k = 0; k < vCount; k++) {
marked[i + k][j] = true;
}
}
}
}

for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
if (marked[i][j]) {
matches.add(new Point(j, i));
// Добавляем частицы для эффекта взрыва
addExplosionParticles(j * TILE_SIZE + TILE_SIZE/2, i * TILE_SIZE + TILE_SIZE/2, COLORS[board[i][j]]);
}
}
}

return matches;
}

private void addExplosionParticles(float x, float y, Color color) {
for (int i = 0; i < 15; i++) {
particles.add(new Particle(x, y, color));
}
}

private int removeMatches() {
List<Point> matches = findAllMatches();
if (matches.isEmpty()) return 0;

int points = matches.size() * 10;
combo++;
points *= (1 + combo / 10);
score += points;

// Добавляем эффект комбо
if (combo > 1) {
addComboEffect(points);
}

for (Point p : matches) {
board[p.y][p.x] = -1;
animations.add(new Animation(p.x, p.y, 1));
}

return points;
}

private void addComboEffect(int points) {
// Эффект комбо будет отображаться при отрисовке
}

private void applyGravity() {
for (int j = 0; j < COLS; j++) {
int writeRow = ROWS - 1;
for (int i = ROWS - 1; i >= 0; i--) {
if (board[i][j] != -1) {
if (writeRow != i) {
animations.add(new Animation(j, i, 0));
}
board[writeRow--][j] = board[i][j];
}
}
for (int i = writeRow; i >= 0; i--) {
board[i][j] = -1;
}
}
}

private void refillBoard() {
Random rand = new Random();
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
if (board[i][j] == -1) {
board[i][j] = rand.nextInt(TYPES_COUNT);
animations.add(new Animation(j, i, 2));
}
}
}
}

private boolean processMatches() {
int points = removeMatches();
if (points > 0) {
applyGravity();
refillBoard();
return true;
}
return false;
}

private void processAllMatches() {
isAnimating = true;
new Thread(() -> {
while (processMatches()) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
isAnimating = false;
combo = 0;
}).start();
}

private void trySwap(int x1, int y1, int x2, int y2) {
if (Math.abs(x1 - x2) + Math.abs(y1 - y2) != 1) return;

int temp = board[y1][x1];
board[y1][x1] = board[y2][x2];
board[y2][x2] = temp;

List<Point> matches = findAllMatches();
if (!matches.isEmpty()) {
processAllMatches();
} else {
temp = board[y1][x1];
board[y1][x1] = board[y2][x2];
board[y2][x2] = temp;
// Анимация ошибки
shakeTile(x1, y1);
shakeTile(x2, y2);
}

repaint();
}

private void shakeTile(int x, int y) {
animations.add(new Animation(x, y, 3));
}

private void updateAnimations() {
animations.removeIf(anim -> {
anim.progress += 0.1;
return anim.progress >= 1;
});
}

private void updateParticles() {
particles.removeIf(p -> p.life <= 0);
particles.forEach(p -> p.update());
}

@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

// Градиентный фон
GradientPaint gradient = new GradientPaint(0, 0, new Color(20, 30, 45),
getWidth(), getHeight(), new Color(35, 45, 65));
g2d.setPaint(gradient);
g2d.fillRect(0, 0, getWidth(), getHeight());

// Рисуем сетку и фишки
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
int x = j * TILE_SIZE;
int y = i * TILE_SIZE;

if (board[i][j] != -1) {
// Проверяем анимацию для этой позиции
boolean isAnimating = false;
for (Animation anim : animations) {
if (anim.x == j && anim.y == i) {
isAnimating = true;
break;
}
}

// Рисуем фишку с эффектом
drawTile(g2d, x, y, board[i][j], isAnimating);
} else {
// Пустая клетка
g2d.setColor(new Color(45, 55, 75));
g2d.fillRoundRect(x + 2, y + 2, TILE_SIZE - 4, TILE_SIZE - 4, 10, 10);
}

// Рисуем рамку
g2d.setColor(new Color(100, 120, 150, 100));
g2d.drawRoundRect(x + 1, y + 1, TILE_SIZE - 2, TILE_SIZE - 2, 12, 12);
}
}

// Рисуем выделение выбранной фишки
if (selectedX != -1 && selectedY != -1 && !isAnimating) {
int x = selectedX * TILE_SIZE;
int y = selectedY * TILE_SIZE;
g2d.setColor(new Color(255, 255, 100, 150));
g2d.setStroke(new BasicStroke(3));
g2d.drawRoundRect(x + 3, y + 3, TILE_SIZE - 6, TILE_SIZE - 6, 12, 12);

// Пульсирующий эффект
float pulse = (float)(Math.sin(Система.currentTimeMillis() / 100.0) * 0.3 + 0.7);
g2d.setColor(new Color(255, 255, 200, (int)(50 * pulse)));
g2d.fillRoundRect(x + 3, y + 3, TILE_SIZE - 6, TILE_SIZE - 6, 12, 12);
}

// Рисуем частицы
for (Particle p : particles) {
float alpha = p.life / 30f;
g2d.setColor(new Color(p.color.getRed(), p.color.getGreen(), p.color.getBlue(), (int)(alpha * 255)));
g2d.fillOval((int)p.x - 3, (int)p.y - 3, 6, 6);
}

// Рисуем интерфейс
drawUI(g2d);
}

private void drawTile(Graphics2D g2d, int x, int y, int type, boolean animating) {
Color baseColor = COLORS[type];
Color lightColor = baseColor.brighter();
Color darkColor = baseColor.darker();

// Градиентная заливка
GradientPaint gradient = new GradientPaint(x, y, lightColor,
x + TILE_SIZE, y + TILE_SIZE, darkColor);
g2d.setPaint(gradient);

// Анимация для падающих/исчезающих фишек
if (animating) {
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.7f));
}

g2d.fillRoundRect(x + 2, y + 2, TILE_SIZE - 4, TILE_SIZE - 4, 15, 15);

// Блик
g2d.setColor(new Color(255, 255, 255, 100));
g2d.fillRoundRect(x + 4, y + 4, TILE_SIZE - 8, TILE_SIZE / 3, 10, 10);

// Узор на фишке
g2d.setColor(new Color(255, 255, 255, 80));
g2d.setStroke(new BasicStroke(2));
g2d.drawRoundRect(x + 5, y + 5, TILE_SIZE - 10, TILE_SIZE - 10, 10, 10);

// Центральная точка
g2d.fillOval(x + TILE_SIZE/2 - 3, y + TILE_SIZE/2 - 3, 6, 6);

if (animating) {
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f));
}
}

private void drawUI(Graphics2D g2d) {
int uiY = ROWS * TILE_SIZE + 20;

// Фон для счета
g2d.setColor(new Color(0, 0, 0, 150));
g2d.fillRoundRect(10, uiY - 10, 250, 60, 15, 15);

// Счет
g2d.setColor(Color.WHITE);
g2d.setFont(new Font("Arial", Font.BOLD, 28));
g2d.drawString("Score: " + score, 20, uiY + 25);

// Комбо
if (combo > 1) {
g2d.setFont(new Font("Arial", Font.BOLD, 24));
g2d.setColor(new Color(255, 200, 0));
String comboText = "COMBO x" + combo + "!";
FontMetrics fm = g2d.getFontMetrics();
int textWidth = fm.stringWidth(comboText);
g2d.drawString(comboText, getWidth() - textWidth - 20, uiY + 25);
}

// Подсказка
g2d.setFont(new Font("Arial", Font.PLAIN, 14));
g2d.setColor(new Color(200, 200, 200));
g2d.drawString("Click on a tile to select, then click on adjacent tile to swap",
20, uiY + 55);
}

public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("✨ Match3 - Три в ряд ✨");
Match3Game game = new Match3Game();

frame.add(game);
frame.pack();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setResizable(false);
frame.setLocationRelativeTo(null);

// Красивая иконка (опционально)
try {
frame.setIconImage(Toolkit.getDefaultToolkit().getImage(
Match3Game.class.getResource("/icon.png")));
} catch (Exception e) {
// Игнорируем, если нет иконки
}

frame.setVisible(true);
});
}
}

См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).