Уроки
Что такое язык программирования?
  «Уровни» языков программирования
Низкоуровневые языки
С точки зрения Wikipedia, язык программирования — формальный язык, предназначенный для записи компьютерных программ. Язык программирования определяет набор лексических, синтаксических и семантических правил, определяющих внешний вид программы и действия, которые выполнит исполнитель (обычно — ЭВМ) под её управлением.

Кажется, что такое определение не добавляет понимания, для чего языки программирования нужны. Посмотрим на это с другой стороны. Вообще процессор воспринимает в качестве инструкций лишь набор нулей и единиц (далее машинный язык). Разные сочетания могут отображать:
  1. Обращение к регистрам процессора
  2. Push или pull операции со стеком
  3. Вызов системных процедур и некоторые другие
Понятно, что для человека написать даже простую программу на машинном языке — непосильная задача. Для решения проблемы придумали Assembler — транслятор в машинный код. Каждая строчка кода на Assembler преобразуется в соответствующую операцию на машинном языке. Вот пример классического Hello, World на диалекте NASM.
        global  _main
        extern  _printf

        section .text
_main:
        push    message
        call    _printf
        add     esp, 4
        ret
message:
        db      'Hello, World', 10, 0
Для определенных задач Assembler является оптимальным решением. Но у него есть ряд проблем:
  1. Диалект Assembler отличается на разных операционных системах. Это значит, что программу, которую вы написали на Windows, не получится запустить на Linux.
  2. Уровень взаимодействия с компьютером слишком низкоуровневый. Даже для такой простой задачи как вывод в консоль надписи Hello, World потребовалось работать с регистрами и стеком процессора напрямую.
  3. Поддержка таких программ обходится дорого.
Под поддержкой обычно понимается время, которое было затрачено на написание кода, тестирование и исправления возможных багов. То есть долгая разработка буквально требует большего количества денег: зарплаты, аренда помещения, отпуска, больничные и так далее.

Представьте себе, что вам нужно написать консольный калькулятор, который бы выдавал ответы на выражения вида 2 + 5 или 15 * 17. Технически это возможно. Но даже такая примитивная программа получилась бы крайне сложной на Assembler.

Языки «среднего» уровня
Язык C стал этаким более высокоуровневым Assembler. Идея простая:
  1. Программист пишет код на языке C, который напоминает английский текст.
  2. Компилятор преобразует код на языке C в машинный код текущей платформы.
  3. Машинный код выполняется процессором.
Для сравнения, вот тот же Hello, World на языке C.
#include <stdio.h>

int main() {
   printf("Hello, World");
   return 0;
}
Теперь все стало гораздо прозрачнее. У нас есть функции, типы данных, параметры, возвращаемые значения и, самое главное, компилятор, который преобразует понятный человеку код в формат для выполнения компьютером. Казалось бы, что на этом можно остановится, но здесь тоже есть нюансы.

Во-первых, язык C по-прежнему довольно близок к «железу», как и Assembler. За счет этого код, хотя и становится проще, в некоторых задачах по-прежнему выходит довольно громоздким. Во-вторых, в C нет сборщика мусора. Это значит, что выделение памяти и ее последующее очищение возлагается на плечи программиста. Посмотрите на пример кода ниже.
#include <stdlib.h>

void f(int n)
{
  int* array = calloc(n, sizeof(int));
  do_some_work(array);
}
В данном фрагменте мы выделяем место в памяти для массива типа int из n элементов и передаем указатель на него в функцию do_some_work. Но после этого память не освобождается (функция free). Если do_some_work внутри себя также не очищает array, то память останется занятой, пока жив процесс операционной системы. Если программа работает достаточно долго, это может привести к тому, что занимаемый объем RAM будет постоянно расти, пока ОС не забьет тревогу и не убьет процесс.

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

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

Транзакции и реляционные базы данных будем разбирать далее в курсе.

Есть и другая проблема с языком C – это кроссплатформенность. Хотя сам язык и позволяет разрабатывать софт под разные операционные системы, конечный продукт – скомпилированный машинный код в бинарном формате - собирается под текущую платформу. Это значит, что «бинарник», который вы скомпилировали в Visual Studio под Windows, вы не сможете запустить на Linux. Для этого придется компилировать под Linux заново.

Проблема заключается еще и в том, что близость языка C к «железу» приводит к наличию функций, которые зависят от конкретной платформы. Например, компилятор под Windows ожидает вызов одних функций при работе с сокетами, а компилятор под Linux – других. Есть способы, как преодолеть это препятствие, однако объяснение этого выходит за рамки нашего курса.

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

При реализации таких программ существуют следующие требования:
  1. Безопасность. Программа не должна «крашиться», например, из-за неверного обращения к адресу памяти.
  2. Скорость разработки. Цикл создания продукта должен быть достаточно быстрым, чтобы подстраиваться под меняющиеся требования рынка.
  3. Поддержка. Бизнес-приложения создаются с расчетом на долгосрочную эксплуатацию. Например, если вы создали сервис по заказу такси через смартфон, со временем его характеристики могут меняться: тарифы оплаты, система отзывов, алгоритмы подбора водителей. Чтобы обеспечить такие требования, во-первых, код должен быть расширяем. Во-вторых, программный код должен быть достаточно понятен, чтобы в нем мог разобраться новый программист.
Вышеописанные требования не могут быть полностью обеспечены языком С, а тем более Assembler. Нам нужна более высокая абстракция, которая скроет от нас низкоуровневые детали и поможет сосредоточиться на бизнес-логике.

Значит, высокоуровневый язык программирования должен предоставлять:
  1. Сборщик мусора (Garbage Collector) – это часть среды выполнения языка программирования, которая автоматически освобождает память для неиспользующихся объектов. Подробнее про GC читайте далее в курсе.
  2. Отсутствие указателей на конкретные участки памяти. Операции с памятью напрямую, как мы уже выяснили, могут быть очень опасны. Представьте себе, что из-за ошибки передачи указателя страховая компания согласовала человеку сумму в 10 раз больше одобренной изначально. Поэтому высокоуровневый язык программирования должен быть освобожден от подобных вещей.
  3. Кроссплатформенность. Это значит, что результирующий «билд» должен запускаться не только на той системе, на которой он был собран, а на любой (или почти любой). Предположим, что вы поставляете клиентам некое бизнес-приложения для запуска на системе Windows, в то время как ваши программисты работают на Linux. Если «билд» не является переносимым, то вам придется дать исходный код продукта вашим клиентам, чтобы они могли скомпилировать его самостоятельно. Во-первых, клиенты могут не располагать нужными компетенциями. Во-вторых, код может быть защищен авторским правом.
  4. Более низкий порог входа. Программисты приходят и уходят. А код, который они написали, остается. Это значит, что язык не должен быть чрезмерно усложнен, чтобы новые разработчики, уровень которых также может быть разным, могли быстро «влиться» в процесс разработки.
Большинство прикладных языков, о которых мы часто слышим, относятся к языкам высокого уровня. Например, Java, Python, C#, Javascript и другие.
Типизация языков программирования
Типы в языках программирования – довольно сложный академический предмет. При желании вы можете найти в Интернете бесчисленное множество статей о теории языков программирования. Но в рамках курса мы не будем уходить далеко, а проведем краткий ликбез, чтобы иметь общее представление о том, с чем вы столкнетесь в рамках вашей карьеры разработчика.
Языки программирования можно разделить на следующие типы:
  1. Со статической/динамической типизацией
  2. С сильной/слабой типизацией
  3. С явной/неявной типизацией
Еще существуют бестиповые языки программирования. К ним можно отнести, например, Assembler или BrainFuck. Подавляющее же большинство языков общего назначения типизированные. Некоторые ошибочно причисляют языки с динамической типизацией (Python, Javascript, PHP) к бестиповым, потому что явно в них типы не указываются. Это утверждение ошибочно. Подробнее об этом читайте ниже.

Рассмотрим каждое деление подробнее.

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

К статическим языкам относятся: Java, C++, C#, Scala. К динамических языкам относятся: Python, Javascript, PHP.

Посмотрите ниже на пример кода на языке Java.
class Main {
    public static void main(String[] args) {
        int a = 45;
        int b = 55;
        System.out.println(sum(a, b));
    }

    public static int sum(int a, int b) {
        return a + b;
    }
}
Если мы попробуем передать в функцию sum вместо int тип String, будет ошибка компиляции, и программа не выполнится. То же самое произойдет, если в переменную a мы попробуем присвоить, например, коллекцию.
Посмотрите ниже на похожий код на языке Python.
def sum(a, b):
    return a + b


if __name__ == '__main__':
    a = 45
    b = 55
    print(sum(a, b))
Тут видны некоторые отличия. Во-первых, мы нигде не указываем типы, потому что интерпретатор определяет их автоматически в процессе выполнения. Во-вторых, если в sum мы передадим значения, которые нельзя складывать (например, строку и число), ошибки компиляции не будет, и программа успешно запустится. Тем не менее в строчке a + b произойдет исключение из-за попытки сложения несовместимых типов (читайте далее про сильную/слабую типизацию).

Преимущества статической типизации:
  1. Проверки типов происходят только один раз — на этапе компиляции. А это значит, что мы всегда уверены в типах значений, которые к нам приходят извне.
  2. Статически типизированные языки практически всегда быстрее динамических, потому что нет накладных расходов на дополнительное определение типа значения в runtime.
  3. Ускорение разработки при поддержке IDE: подсказка при вызове функций, autocomplete и так далее.

Преимущества динамической типизации:
  1. Простота создания универсальных коллекций из разных типов. Однако такая необходимость возникает редко.
  2. Легкость в освоении. Такие языки хорошо подходят для тех, кто хочет понять основы программирования.

В разработке промышленных решений (Java здесь лидер рынка) обычно применяют языки со статической типизацией. Есть ряд причин:
  1. Несоответствие типов гарантирует, что программа не запустится. В динамических же языках выполнение дойдет ровно до той строчки, где интерпретатор распознает ошибку. Представьте, что вы обрабатываете запрос клиента, который формирует заказ и снимает деньги со счета. Будет не очень приятно, если из-за ошибки типов заказ сформируется, а деньги не снимутся.
  2. Сложность предметной области. Промышленные приложения автоматизируют бизнес-сценарии, которые сложны сами по себе. Статически типизированные языки позволяют описать контракты, корректность вызова которых будет проверяться компилятором, а не программистом.
  3. В бизнес-приложениях может быть довольно много кода. Качественный autocomplete, который IDE предоставляют для статических языков, сильно ускоряют разработку.
Для динамических языков сложно реализовать хороший autocomplete, потому что тип точно не известен, пока программа не начнет выполняться. Поэтому нельзя сказать однозначно, какими атрибутами обладает переданный объект.

Пару слов о var
В Java, Scala, Kotlin и некоторых других языках программирования есть возможность не указывать тип явно, а заменить его на ключевое слово var, чтобы компилятор определил его автоматически. Некоторые программисты считают, что это внедрение динамической типизации в статические языки. Это не так. Посмотрите ниже на пример кода Java с использованием var.
class Main {
    public static void main(String[] args) {
        var a = 45;
        var b = 55;
        System.out.println(sum(a, b));
    }

    public static int sum(int a, int b) {
        return a + b;
    }
}
В данном случае, тип int определился компилятором автоматически. Но если мы передадим в функцию значение, отличное от int, или аналогичным образом попытаемся переопределить переменную, также будет ошибка компиляции. Посмотрите на пример ниже.
class Main {
    public static void main(String[] args) {
        var temp = "some_string";
        // ошибка компиляции: temp типа String, нельзя присвоить int
        temp = 78;
        var a = 45;
        var b = "55";
        System.out.println(
            // ошибка компиляции: b типа String, но ожидался int
            sum(a, b)
        );
    }

    public static int sum(int a, int b) {
        return a + b;
    }
}
Сильная/слабая типизация
Сильная/слабая типизация означает отсутствие или наличие автоматического преобразование типов. Если типизация слабая, то вы можете в одном выражении применять совершенно не связанные между собой объекты, и компилятор (или интерпретатор) приведет их к одному типу. Если же типизация сильная, то подобные смешивания недопустимы. Программист должен вручную привести все типы к единому значению.

Важно отметить, что сильная/слабая типизация не связана со статической/динамической. Язык может быть статическим и слабым (язык C) или динамическим и сильным (язык PHP).

Рассмотрим сильную типизацию на примере Python. Посмотрите на код ниже.
s = "Hello, World"
s1 = {
    'key': 'value'
}
# TypeError:  unsupported operand type(s) for +: 'dict' and 'str'
result_str = s + s1
В данном случае мы пытаемся сложить строку Hello, World и словарь (объект формата ключ-значение). В Python нет преобразований типов в выражениях, поэтому операция завершается с ошибкой. Чтобы это исправить, нужно явно трансформировать словарь в строку перед операцией сложения. Посмотрите на исправленный пример кода ниже:
s = "Hello, World"
s1 = {
    'key': 'value'
}
# {'key': 1}d"
result_str = s + str(s1)
Слабая типизация ведет себя иначе. Посмотрите на похожий код на языке Javascript ниже:
const s = "Hello, World";
const s1 = {
    key: 'value'
}
// Hello, World[object Object]
result_str = s + s1;
Как можно заметить, s1 был автоматически преобразован к строке вида [object Object]. После конкатенации итоговый результат равен Hello, World[object Object].

Это может показаться удобным, так как теперь интерпретатор решает проблемы конвертации типов за нас. В действительности же это может приводить к неожиданным результатами и трудноуловимым багам. Например, результат всех выражений ниже на Javascript равен true.
[] == ![];
+[] == +![];
0 == +false
0 == false
false == []
false == ![]
Как можно догадаться, подобные «удобства» только вызывают дополнительные сложности. Поэтому в Javascript есть оператор строгого равенства – === – который не выполняет никаких приведений типов самостоятельно.
0 === false; // false
false === [] // false
0 === 0 // true
Стоит отметить, что в Java, языке с сильной типизацией, также присутствуют некоторые преобразования типов. Например, при операциях с численными типами.
class Main {
    public static void main(String[] args) {
        int i = 12;
        double d = 13.5;
        // d == 12.0
        d = i;
    }
}
Но в обратную сторону это не работает. Будет ошибка, так как при присваивании double к int возможна потеря данных.
class Main {
    public static void main(String[] args) {
        int i = 12;
        double d = 13.5;
        // incompatible types: possible lossy conversion from double to int
        i = d;
    }
}
Также безопасно можно конкатенировать строки и числа.
class Main {
    public static void main(String[] args) {
        // abc10
        System.out.println("abc" + 10);
    }
}
Явная/неявная типизация
Неявная типизация относится к автоматическому выводу типов компилятором без их указания программистом. Самый простой пример неявной типизации мы рассматривали ранее – это ключевое слово var. Также есть и более сложные варианты. Например, в Haskell можно не указывать тип возвращаемого значения и параметры функции. Компилятор выведет их автоматически на основании операций, которые выполняются со значениями. Посмотрите на пример кода ниже:
-- Без явного указания типа
add (x, y) = x + y

-- Явное указание типа
add :: (Integer, Integer) -> Integer
add (x, y) = x + y
Обратите внимание, что критерии явной и неявной типизации имеет отношение только к статическим языкам, так как в динамических язках типы и так не указываются, поскольку определяются во время выполнения.
Вывод по языкам программирования
Может сложиться впечатление, что мы пытаемся представить ситуацию так, как будто один язык программирования лучше другого. Это не так. Язык – это инструмент для решения задачи. Разные языки лучше себя показывают в различных сценариях. Нет смысла пытаться забить гвоздь пассатижами, если есть молоток, которым сделать это гораздо удобнее. Здесь также можно вспомнить шутку Бьерна Страуструпа, создателя языка C++.

Есть два вида языков программирования. На одни все жалуются, другими никто не пользуется :)
Начинаем работать с языком Java
Наш курс посвящен языку программированию Java, а также инструментам, которые с ним связаны. Так что давайте начнем с простейшего примера: программы Hello, world!. Первым делом разберемся с установкой IDE.
Установка Intellij Idea
IDE (Integrated Development Environment) — это среда разработки, которая позволяет эффективно писать программы для того или иного языка программирования. Проще говоря, это такой блокнот на «максималках» с autocomplete, возможностью запуска программ, дебага и так далее.

В качестве IDE мы рекомендуем использовать Intellij Idea. Но также можете рассмотреть Visual Studio Code. Это бесплатный и кроссплатформенный инструмент для написания кода с большим количество расширений и плагинов.
Давайте напишем Hello, World! на Java с помощью Intellij Idea. Скачайте Intellij Idea Community Edition по этой ссылке. Эта версия обладает чуть меньшей функциональностью, чем Ultimate, но зато является бесплатной.

Иногда ВУЗы предоставляют лицензии для Ultimate версий продуктов компании Jetbrains (Intellij Idea как раз является таковым). Если это ваш случай, то рекомендуем выбрать именно Ultimate.
Создание и запуск проекта
После установки выберите пункт Create new project и тип проекта Maven.

Про Maven и другие сборщики Java-проектов читайте дальше по курсу.

В окне вам также предложат указать версию Java SDK. По умолчанию она устанавливается вместе с IDE. Если этого не произошло, то скачайте Java 17 по этой ссылке.

Далее выберите названия проекта, путь и нажмите кнопку Create.

Внизу вы также увидите настройки Maven Archetypes. Пока можете оставить их без изменений.

Теперь в панели слева нажмите правой кнопкой мыши на директорию src/main/java и выберите пункт Java Class. В качестве названия напишите org.example.Main.

org.example — это название package (пакета), где класс будет расположен. А Main же — название самого класса. Если вы ничего не меняли в настройках Maven Archetypes, то package будет называться org.example. Иначе вам нужно подставить соответствующее значение.

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

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

Посмотрите ниже на пример кода на Java, который выводит Hello, World в консоль:
public class Main { // (1)

  public static void main(String[] args) { // (2), (4)
    System.out.println("Hello, world!");   // (3)
  } // (4)
}
Пояснения
  1. Объявление класса. Ключевое слово public означает, что доступ к данному классу открыт из всех частей программы. Ключевое слово class говорит о том, что текущая конструкция – это объявление именно класса, а не чего-либо ещё. Main – это название класса, которое может быть любым на ваш выбор.
  2. Объявление метода. Ключевое слово public означает, что доступ к данному методу открыт из всех частей программы. Ключевое слово static пока пропустим. Пока просто запомним, что у метода main оно должно быть указано. Ключевое слово void обозначает тип значения (число, строка и т.д.), которое возвращает метод. Void означает, что возвращаемое значение отсутствует. main – это название метода. Далее в скобках идут аргументы метода – массив строк. Открывающая и закрывающая (4) фигурные скобки – это ограничители метода: всё, что написано внутри них, принадлежит этому методу.
  3. Печать строки на экран
Обратите внимание, что рядом с функцией main появился знак ▶.
Нажмите на него, что скомпилировать программу и запустить.
Hello, world!
Если вы увидели такую строчку в консоли внизу, вы сделали все правильно. Поздравляем!

Далее в курсе мы рассмотрим подробнее, как именно происходит компиляция и запуск программ на Java. Пока достаточно того, что вы запускаете их через Intellij Idea.
Основы ООП и программирования в Java
Парадигмы программирования
Парадигма программирования – это система идей и понятий, определяющих фундаментальный стиль программирования. Разные языки программирования придерживаются разных парадигм:
  • структурной (C)
  • функциональной (Lisp, Clojure, Haskell)
  • логической (Prolog)
  • объектно-ориентированной (Java, Swift)
Первым языком программирования, в котором были предложены такие понятия, как ООП, как классы и объекты, был язык Симула, появившийся в 1967 году. А сам объектно-ориентированный подход к написанию программ впервые появился в ЯП Smalltalk - он и стал первым широко распространённым ООП-языком программирования.
В настоящее время большинство прикладных языков программирования реализуют объектно-ориентированную парадигму.

Принципы ООП
Что такое класс?
Ключевым понятием в ООП является класс. Класс — это некая сущность, содержащая данные, а также методы для их обработки. Классы нужны для того, чтобы описывать с их помощью объекты реального мира.
Давайте создадим с вами класс. В том же package, где расположен класс Main, создайте новый и назовите его Car.
package org.example;

public class Car {

}
Класс — это представление некой сущности реального мира. Например, классом может выступать машина, книга, животное, устройство и так далее.

Каждый класс не обязательно представляет что-то из реального мира. Некоторые классы могут использоваться как композиты, чтобы объединять другие классы. Или же реализовать тот или иной паттерн проектирования, чтобы сделать код более читаемым и поддерживаемым. Мы подробнее обсудим это в модуле, посвященном unit-тестированию. Там мы также затронем паттерны GoF и принципы SOLID.

Какие характеристики могут быть у машины? Например, название бренда, год выпуска и цвет. Давайте добавим соответствующие атрибуты.
package org.example;

public class Car {

  public String brand;
  public int year;
  public String color;
}
Название бренда заполняется обычной строкой, год — числом типа int, а цвет — также строкой.

Подробнее про разницу между примитивами (например, int) и классами поговорим дальше.

Хранить год как число и год как строку — не лучшее решение с точки зрения дизайна программы. Дальше мы посмотрим, как мощь ООП позволит нам улучшить этот класс.

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

Посмотрите на пример кода ниже, где мы создаем объект класса Car, заполняем его атрибуты значениями и выводим содержимое в консоль.
public class Main {

  public static void main(String[] args) {
    Car car = new Car();
    car.brand = "Lada";
    car.year = 2023;
    car.color = "Красный";

    System.out.println("Бренд: " + car.brand);
    System.out.println("Год выпуска: " + car.year);
    System.out.println("Цвет: " + car.color);
  }
}
В первой строчке мы через конструктор создаем объект класса Car. Конструктор — это специальная функция, который строит объект на основе переданных атрибутов. Если вы явно не добавляете никаких конструкторов, то Java все равно создаст для класса конструктор без аргументов (или же конструктор по умолчанию). В данном случае new Car() – это и есть обращение к конструктору. Так как аргументов нет, то скобки пустые.

Далее мы заполняем атрибуты класса значениями. Помните модификатор public в полях класса? Он означает, что кто угодно может обращаться напрямую к атрибутам и читать их или менять значение.

После этого мы выводим значения каждого поля отдельной строчкой в консоль. Посмотрите на пример ниже. Если вы получили такой же результат, то сделали все правильно!
Бренд: Lada
Год выпуска: 2023
Цвет: Красный
Как передаются значения в Java?
Прежде чем мы познакомимся с остальными принципами ООП (инкапсуляция и полиморфизм), давайте разберемся с тем, как в Java передаются значения.

Все значения в Java делятся на два типа: примитивы и объекты. К примитивам относятся следующие типы данных:
  • byte – 8-битовое целое число со знаком. Может принимать значение от -128 до 127.
  • short – 16-битовое целое число со знаком. Может принимать значение от -32768 до 32767.
  • int – 32-битовое целое число со знаком. Может принимать значение от -2^31 до 2^31 - 1.
  • long – 64-битовое целое число. Число со знаком может принимать значение от -2^63 до 2^63 - 1.
  • float – 32-битовое число с плавающей запятой.
  • double – 64-битовое число с плавающей запятой.
  • boolean – логический тип данных, может иметь только 2 значения, true или false.
  • char – один символ в формате Unicode.
Любые другие значения являются объектами.

Если мы передаем такое значение в функцию, то оно копируется. Посмотрите на пример ниже:
public class Main {

  public static void main(String[] args) {
    int myNum = 10;
    printNumber(myNum);
    System.out.println(myNum);
  }
  
  public static void printNumber(int number) {
    number = number + 10;
    System.out.println(number);
  }
}
Как думаете, что выведется в консоль? Правильный ответ: 20, а потом 10. Несмотря на то, что в printNumber для переданного аргумента number мы присвоили другое значение, эта операция никак не повлияет на myNum, который мы объявили в функции main. Потому что при вызове printNumber мы передали не ссылку на myNum, а копию значения.

Что же с объектами? Посмотрите на пример кода ниже.
public class Main {

  public static void main(String[] args) {
    Car car = new Car();
    car.brand = "Lada";
    printCar(car);
    System.out.println(car.brand);
  }
  
  public static void printCar(Car car) {
    car.brand = "Moskvich";
    System.out.println(car.brand);
  }
}
Ранее мы говорили о том, что примитивы копируются при передаче. Наверное, с объектами должно происходить то же самое, верно? Тогда в консоли мы увидим: Moskvich, а затем Lada.

На самом же деле результат будет Moskvich два раза. В чем же тут дело? Здесь требуется осознать следующую особенность Java. Это язык с концепцией pass by value. То есть значения копируются, когда вы передаете их в метод. Для примитивов это работает ровно так, как вы ожидаете. Но в случае объектов копируется не сам объект, а ссылка на него. Посмотрите на схему ниже, чтобы понять эту логику.
Когда мы создаем объект (экземляр класса Car) через new, Java выделяет память под этот объект в heap (куче). Переменная же car хранит ссылку на этот объект. Если мы передаем переменную car в какой-то другой метод, то копируется сама ссылка, но не объект, находящийся в куче. Таким образом, если мы меняем содержимое объекта (например, поле), то он редактируется в куче. А значит, теперь все участки кода, у которых есть эта ссылка, смотрят на объект с измененным полем.

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

Исходя из того, что копируется ссылка, можно сделать вывод, что повторное присвоение другого объекта не приведет к редактированию его копии в heap. Посмотрите на пример кода ниже:
public class Main {

  public static void main(String[] args) {
    Car car = new Car();
    car.brand = "Lada";
    printCar(car);
    System.out.println(car.brand);
  }
  
  public static void printCar(Car car) {
    car = new Car();
    car.brand = "Moskvich";
    System.out.println(car.brand);
  }
}
Здесь выведется Moskvich, а затем Lada. Так как Java орудует не указателями на область памяти, а лишь копиями ссылок, то повторное присвоение в переменную Car никак не влияет на изначальный объект, который мы создали. В этом отличие Java от C/C++, где, передав указатель и присвоив в него другое значение, вы меняете саму область памяти, а не просто локальную переменную.

Мы хотим, чтобы в этом блоке вы зафиксировали несколько очень важных выводов:
  1. Java – язык pass by value. Значение копируются при передаче.
  2. В Java нет указателей, как в C/C++.
  3. Если мы передаем объект, то копируется лишь ссылка на него, но не сам объект.
Что такое инкапсуляция?
Хотя мы и создали класс Car, пока что наш код напоминает процедурный стиль. Да и класс больше походит на обычную структуру данных. Чем же все-таки класс так примечателен?

Сначала давайте рассмотрим следующую проблему. Что если в класс добавится новый атрибут? Или же мы захотим выводить информацию о машине в консоль как-то по-другому. Ответ – нам придется менять код в функции main. Нюанс еще и в том, что класс Car может использоваться в разных частях программы. А значит, код придется править в нескольких местах!

В процедурных языках (например, C) эта проблема решается внедрением функций или процедур. В Java это реализуется с помощью методов с модификатором static. Посмотрите на пример кода ниже:
public class CarUtil {

  public static void print(Car car) {
    System.out.println("Бренд: " + car.brand);
    System.out.println("Год выпуск: " + car.year);
    System.out.println("Цвет: " + car.color);
  }
}
Если функция является static, то мы можем к ней обращаться напрямую CarUtil.print(car). По сути, это то же самое, что и обычные функции в языках вроде C, C++ или Python.

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

Причины этого вы поймете чуть позже, когда мы разберем наследование и полиморфизм.
Какова же альтернатива? Давайте добавим print напрямую в класс Car, но без модификатора static. Посмотрите на пример кода ниже:
public class Car {

  public String brand;
  public int year;
  public String color;

  public void print() {
    System.out.println("Бренд: " + this.brand);
    System.out.println("Год выпуск: " + this.year);
    System.out.println("Цвет: " + this.color);
  }
}
Ключевое слово this указывает на экземпляр текущего объекта. Проще говоря, через this внутри метода мы можем обратиться к себе самому. Поскольку метод не статический, мы можем обращаться к полям класса напрямую, потому что они привязаны к конкретному экземпляру.

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

Посмотрите на пример кода ниже:
public class Main {

  public static void main(String[] args) {
    Car car = new Car();
    car.brand = "Lada";
    car.year = 2023;
    car.color = "Красный";

    CarUtil.print(car);
    car.print();
  }
}
В первом случае мы вызываем статический метод CarUtil.print. При этом нам не нужно создавать экземпляр класса CarUtil. Во втором же варианте мы напрямую обращаемся к экземпляру класса Car и просим его распечатать.

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

Давайте вернемся к концепции конструктора класса. С его помощью можно передавать определенные параметры, которые сразу же допустимо фиксировать в полях. Посмотрите на пример ниже, где мы добавляем конструктор для класса Car.
public class Car {
    public String brand;
    public int year;
    public String color;

    public Car(String brand, int year, String color) {
        this.brand = brand;
        this.year = year;
        this.color = color;
    }

    public void print() {
        System.out.println("Бренд: " + this.brand);
        System.out.println("Год выпуск: " + this.year);
        System.out.println("Цвет: " + this.color);
    }
}
Теперь при создании экземпляра класса Car мы должны явно передать в конструкторе всю информацию, которая требуется для создания машины.

Если вы используете Idea, то конструктор для класса можно сгененировать, нажав на него правой кнопкой мыши (пункт Generate...)

Откройте снова функцию main. Вы увидите, что создание класса Car выделено красным. Это ошибка компиляции, она возникла, потому что мы не передали в метод создания нужные аргументы. А это означает, что теперь мы не можем создать экземпляр Car, не передав сразу все необходимые поля. То есть на уровне кода мы блокируем возможность создания Car с частично заполненными полями.

Давайте поправим код. Посмотрите на пример ниже:
public class Main {
    public static void main(String[] args) {
        Car car = new Car("Lada", 2023, "Красный");
        car.print();
    }
}
Заметили, насколько код стал короче и понятнее? Теперь детали реализации print скрыты внутри класса Car. Нам не важно, как именно реализована логика. Мы просто обращаемся к публичному API (Application Programming Interface). Теперь мы готовы сформулировать определение инкапсуляции.

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

Осталось только разобраться с сокрытием данных. Поля в классе Car по-прежнему public. То есть кто угодно может обратиться к ним и изменить их значение. Это нарушает идею инкапсуляции. Давайте поменяем модификатор public на private. Посмотрите на пример кода ниже:
public class Car {
    private String brand;
    private int year;
    private String color;

    public Car(String brand, int year, String color) {
        this.brand = brand;
        this.year = year;
        this.color = color;
    }

    public void print() {
        System.out.println("Бренд: " + this.brand);
        System.out.println("Год выпуск: " + this.year);
        System.out.println("Цвет: " + this.color);
    }
}
Теперь обращение к полям допустимо только через this внутри самого класса Car. Попробуйте явно поменять значение поля brand в функции main. Вы увидите, что возникнет ошибка компиляции.

Геттеры
Что если мы все же хотим получать информацию о полях класса Car наружу? Допустим, нам не нужно менять их значение, но требуется узнать, что находится внутри. Можно, конечно, вернуть модификатор public обратно, но тогда поля будут открыты и для редактирования, а это нас не устраивает.

Чтобы решить эту проблему, в Java использует такой концепт как геттеры. Это методы, которые возвращают значение того или иного поля класса. Посмотрите на пример кода ниже.
public class Car {
    private String brand;
    private int year;
    private String color;

    public Car(String brand, int year, String color) {
        this.brand = brand;
        this.year = year;
        this.color = color;
    }

    public void print() {
        System.out.println("Бренд: " + this.brand);
        System.out.println("Год выпуск: " + this.year);
        System.out.println("Цвет: " + this.color);
    }

    public String getBrand() {
        return brand;
    }

    public int getYear() {
        return year;
    }

    public String getColor() {
        return color;
    }
}
Методы getBrand, getYear и getColor возвращают значения полей, но не позволяют их менять. Таким образом, мы можем запросить контент того или иного класса, не нарушая его целостность.

Геттеры – очень популярная концепция. С вероятностью близкой к 100% вы увидите большое их количество в разных кодовых базах на Java. Но геттеры, как вы уже могли догадаться, нарушают принцип инкапсуляции, потому что теперь внешний код получает значение полей напрямую вместо того, чтобы вызвать то или иное поведение. Поэтому мы рекомендуем не злоупотреблять геттерами. Если вы хотите их внедрить, то подумайте, можете ли вы как-то обойтись без них.

Сеттеры и иммутабельность
Иногда нам необходимо проводить определенные модификации над объектами. Допустим, мы создали машину, а потом хотим поменять у нее название бренда. Часто для таких случаев используют сеттеры. В отличие от геттеров, эти методы ничего не возвращают, но меняет значение того или иного поля в классе. Посмотрите на пример кода ниже:
public class Car {
    private String brand;
    private int year;
    private String color;

    public Car(String brand, int year, String color) {
        this.brand = brand;
        this.year = year;
        this.color = color;
    }

    public void print() {
        System.out.println("Бренд: " + this.brand);
        System.out.println("Год выпуск: " + this.year);
        System.out.println("Цвет: " + this.color);
    }

    public void setBrand(String brand) {
        this.brand = brand;
    }

    public void setYear(int year) {
        this.year = year;
    }

    public void setColor(String color) {
        this.color = color;
    }
}
И сеттеры, и геттеры также можно сгенерировать в Idea.

Отличие сеттера от публичного поля в том, что сеттер – это метод. А значит, вы можете добавлять в него какие-то проверки. Например, можно менять название бренда, только если его длина меньше 50. Посмотрите на код ниже:
public class Car {
    /* поля и конструктор */
  
    public void setBrand(String brand) {
        if (brand.length() < 50) {
            this.brand = brand;
        }
    }
}
Далее по курсу мы обсудим исключения как способ обработки ошибок.

Главная проблема сеттеров в том, что контент объекта меняется неожиданно. Например, вы передали куда-то объект Car, а его контент неожиданно изменился. Альтернативный вариант заключается в том, чтобы создавать новый объект с измененным полем вместо того, чтобы править существующий.

Посмотрите на пример ниже:
public class Car {
    /* поля и конструктор */

    public Car withBrand(String newBrand) {
        return new Car(newBrand, this.year, this.color);
    }
}
Метод withBrand не меняет состояние существующего экземпляра, но возвращает новый с измененным полем. Такой подход называется иммутабельностью. То есть контент объекта никогда не меняется, поэтому с ним можно безопасно работать. Более того, методы with можно комбинировать в цепочку. Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        Car car = new Car("Lada", 2023, "Красный");
        Car newCar =
            car.withBrand("Moskvich")
                    .withYear(1965)
                    .withColor("Белый");
        newCar.print();
    }
}
В результате работы метода print получим:
Бренд: Moskvich
Год выпуск: 1965
Цвет: Белый
Мы советуем использовать иммутабельные классы везде, где это возможно. А к сеттерам прибегать в крайних случаях.

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

Ключевое слово final
Если вы проектируете свои классы как иммутабельные, имеет смысл отмечать поля как final. В этом случае присваивать значения разрешается только внутри конструктора единожды. Посмотрите на пример кода ниже:
public class Car {
    private final String brand;
    private final int year;
    private final String color;

    public Car(String brand, int year, String color) {
        this.brand = brand;
        this.year = year;
        this.color = color;
    }

    public void print() {
        System.out.println("Бренд: " + this.brand);
        System.out.println("Год выпуск: " + this.year);
        System.out.println("Цвет: " + this.color);
    }
}
Даже если мы добавим сеттер и попытаемся изменить значение у существующего экземпляра класса, то получим ошибку компиляции. Такое решение позволяет легче гарантировать иммутабельность объектов.
Что такое полиморфизм?
Полиморфизм - одно из ключевых свойств ООП, а также причина, которая позволяет писать объектно-ориентированный код более расширяемым и поддерживаемым. В Java полиморфизм имеет несколько особенностей. Давайте начнем с простого примера.

Наследование полей классов
Предположим, что мы хотим иметь дело с разными видами техники в нашей программе: машины и самолеты. Отличие машины от самолета в том, что для последнего в качестве атрибутов мы храним только год и цвет. Помимо всего прочего, оба вида техники предоставляют метод print, чтобы напечатать информацию о ней в консоль.
Для начала давайте объявим новый класс Airplane. Посмотрите на пример кода ниже:
public class Airplane {
    private final int year;
    private final String color;

    public Airplane(int year, String color) {
        this.year = year;
        this.color = color;
    }

    public void print() {
        System.out.println("Год выпуска самолета: " + this.year);
        System.out.println("Цвет самолета: " + this.color);
    }
}
Можно сразу заметить, что поля year и color обладают такими же типами, как и в классе Car. Можем ли мы как-то переиспользовать код, чтобы убрать дублирование? Конечно, можем. Для этого на помощь придет наследование. Для начала объявим класс Vehicle. Посмотрите на пример кода ниже:
public class Vehicle {
    protected final int year;
    protected final String color;

    public Vehicle(int year, String color) {
        this.year = year;
        this.color = color;
    }
}
Обратите внимание на модификатор protected у полей. Он ведет себе так же, как и private, с тем исключением, что доступ к полю будут иметь еще и классы-наследники.

Теперь немного поменяем декларацию класса Airplane. Посмотрите на код ниже:
public class Airplane extends Vehicle {
    public Airplane(int year, String color) {
        super(year, color);
    }

    public void print() {
        System.out.println("Год выпуска самолета: " + this.year);
        System.out.println("Цвет самолета: " + this.color);
    }
}
Конструкция Airplane extends Vehicle означает, что класс Airplane наследуется от Vehicle. Поскольку поля в Vehicle объявлены как protected, наследники имеют к ним доступ через this (посмотрите на метод print). В то же самое время эти поля final, так что мы обязаны объявить их через конструктор. Для этого есть ключевое слово super, которое позволяет в конструкторе текущего класса (Airplane) вызвать конструктор родительского класса (Vehicle).

По таком же принципу мы можем отрефакторить класс Car. Посмотрите на пример кода ниже.
public class Car extends Vehicle {
    private final String brand;

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

    public void print() {
        System.out.println("Бренд: " + this.brand);
        System.out.println("Год выпуска: " + this.year);
        System.out.println("Цвет: " + this.color);
    }
}
Поле brand является специфическим для Car, так что мы объявляем его явно. Поля же year и color наследуются от класса Vehicle.

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

Наследование методов класса
Предположим, что мы хотим выделить утилитную функцию (с модификатором static), чтобы печатать информацию о технике и перед этим выводить логирующее сообщение. Посмотрите на пример кода ниже:
public class VehicleUtil {
    public static void printVehicle(Vehicle vehicle) {
        System.out.println("Выводим информацию о технике");
        vehicle.print();
    }
}
Одна из важных особенностей наследования в том, что любой дочерний класс обладает типом вышестоящего класса. Проще говоря, если Car наследует Vehicle, то мы можем передавать экземпляр Car туда, где ожидается Vehicle.

Правда в обратную сторону это не работает. То есть, если метод принимает на вход Car, то передать Vehicle не получится – будет ошибка компиляции. В целом, такое поведение логично. Ведь Car гарантированно является Vehicle, поскольку наследует его. Но вот любой Vehicle необязательно будет Car, потому что у Vehicle могут быть и другие наследники.

В Java хорошей практикой считается указывать в качестве аргумента тот параметр, который является самым высшим классом в иерархии наследования. Например, если Vehicle уже предоставляет все необходимые данные, то лучше указать его, а не Car или Airplane. При таком раскладе метод получается более расширяемым и обобщенным. Если вдруг появится новый наследник Vehicle (например, Ship), то не нужно будет менять уже существующий код.

Но давайте вернемся к функции VehicleUtil.printVehicle выше. Заметьте, что мы вызываем метод print. Если вы уже попробовали добавить этот код, то увидели, что он не компилируется. Проблема в том, что метод printотсутствует в Vehicle. Посмотрите на исправленный вариант Vehicle ниже:
public class Vehicle {
  protected final int year;
  protected final String color;

  public Vehicle(int year, String color) {
    this.year = year;
    this.color = color;
  }
  
  public void print() {
    // do nothing
  }
}
Теперь код компилируется. Напишите функцию main по примеру ниже и запустите:
public class Main {
    public static void main(String[] args) {
        Car car = new Car("Lada", 2023, "Красный");
        Airplane airplane = new Airplane(1980, "Boeing");
        VehicleUtil.printVehicle(car);
        VehicleUtil.printVehicle(airplane);
    }
}
Если у вас такой результат в консоли, то вы все сделали правильно:
Выводим информацию о технике
Бренд: Lada
Год выпуск: 2023
Цвет: Красный
Выводим информацию о технике
Год выпуска самолета: 1980
Цвет самолета: Boeing
В чем же здесь дело? Ведь функция Vehicle.print объявлена с пустым телом. Как так вышло, что мы увидели результат работы Car.print и Airplane.print?

Суть в виртуальных методах. Java смотрит не на тип параметра в методе, а на класс того объекта, который на самом деле пришел во время выполнения программы. А далее вызывает метод print именно на нем. Иначе говоря, если в дочернем классе вы переопределили метод из родительского класса, то в итоге вызов будет передан на дочерний класс (если его экземпляр вы передаете).

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

В Java все методы виртуальные по умолчанию. Но в C++ это не так. Там нужно явно добавлять слово virtual в сигнатуру метода. Если этого не сделать, то тот же пример выше на C++ приведет к вызову именно родительского метода Vehicle.print

Абстрактные классы
Что если мы добавим нового потомка для Vehicle, но забудем переопределить функцию print? В такой ситуации будет вызван метод print из родителя, который не даст нам ожидаемого поведения.

Можно ли как-то сообщить Java, что отсутствие метода print в классе-потомке должно приводить к ошибке? Конечно, да. Для этого есть абстрактные классы. Посмотрите на переработанный вариант Vehicle ниже:
public abstract class Vehicle {
    protected final int year;
    protected final String color;

    public Vehicle(int year, String color) {
        this.year = year;
        this.color = color;
    }

    public abstract void print();
}
Во-первых, мы добавили ключевое слово abstract рядом с class. Во-вторых, метод print теперь тоже отмечен как abstract. С одной стороны, абстрактный метод не может иметь тела. С другой стороны, любой класс, который наследует абстрактный, должен реализовать все его абстрактные методы. Попробуйте сейчас удалить функцию print из класса Car. Вы увидите, что это приведет к ошибке компиляции. Такое решение позволяет писать более безопасный код: если метод абстрактный, то его наследник точно его реализовал.

У абстрактных классов есть еще одно свойство: их экземпляр нельзя создать напрямую. Посмотрите на блок кода ниже:
public class Main {
    public static void main(String[] args) {
        Vehicle vehicle = new Vehicle(1983, "Ford");
        vehicle.print();
    }
}
Этот пример тоже не компилируется. Опять же, такое поведение абсолютно логично. Раз класс абстрактный, он может содержать абстрактные методы. А у таких методов не определено поведение (может быть задано только наследниками). Если можно было бы создать Vehicle напрямую, то не понятно, к чему бы привел вызов Vehicle.print.

Интерфейсы
Классы и абстрактные классы – не единственные способы достижения полиморфизма в Java. Есть еще интерфейсы. Они похожи на абстрактные классы, но не могут содержать полей, а предоставляют только декларацию абстрактных функций. Посмотрите на пример интерфейса Vehicle ниже:
public interface Vehicle {
    void print();
}
Все методы интерфейса являются абстрактными и public по умолчанию, так что добавлять эти ключевые слова в сигнатуру не нужно. Давайте немного перепишем класс Car. Посмотрите на пример ниже:
public class Car implements Vehicle {
    private final String brand;
    private final int year;
    private final String color;

    public Car(String brand, int year, String color) {
        this.brand = brand;
        this.year = year;
        this.color = color;
    }

    @Override
    public void print() {
        System.out.println("Бренд: " + this.brand);
        System.out.println("Год выпуска: " + this.year);
        System.out.println("Цвет: " + this.color);
    }
}
Как видите, отличия от наследования класса только в слове implements вместо extends.

Обратите внимание на аннотацию @Override над методом print. Она сигнализирует, что метод был переопределен из родительского класса или интерфейса. Хотя ставить ее не обязательно, все же рекомендуется ее не пропускать. Так будет сразу понятно, какой метод мы переопределили.

Наследование нескольких классов и интерфейсов
Между классами и интерфейсами есть еще одна значительная разница. Можно наследовать несколько интерфейсов, но только один класс. Попробуйте в extends указать несколько классов и увидите ошибку компиляции. Причина такого запрета кроется в проблеме ромбовидного наследования. C интерфейсами же таких трудностей не возникает, потому что они определяют лишь контракт, но не поведение.

Анонимные классы
С понятием абстрактных классов и интерфейсов связано еще одна фича языка Java: анонимные классы. Посмотрите на пример кода ниже (предполагаем, что Vehicle – это интерфейс):
public class Main {
    public static void main(String[] args) {
        Vehicle vehicle = new Vehicle(1983, "Ford");
        vehicle.print();
    }
}
Ситуация здесь точно такая же, как и абстрактными классами – нельзя создать экземпляр напрямую. Тем не менее, иногда мы хотим добавить какую-то реализацию интерфейса, которая будет использована единожды. Кажется, что в таком случае нет смысла добавлять отдельного наследника в виде класса. Посмотрите на пример кода ниже с использованием анонимного класса:
public class Main {
    public static void main(String[] args) {
        Vehicle vehicle = new Vehicle() {
            @Override
            public void print() {
              System.out.println("Машина Ford 1983 года");
            }
        };
        vehicle.print();
    }
}
Здесь мы создали наследника для Vehicle на месте, сразу же определив реализацию метода print. Такой же синтаксис допустим и для абстрактных классов.

Лямбды
Начиная с Java 8 в языке можно использовать лямбды. Это анонимные классы, которые созданы от интерфейсов с одним методом. Посмотрите на переработанный вариант с анонимным классом Vehicle ниже:
public class Main {
    public static void main(String[] args) {
        Vehicle vehicle = () -> System.out.println("Машина Ford 1983 года");
        vehicle.print();
    }
}
Суть здесь абсолютно такая же, как и с обычным анонимным классом. Но код выглядит гораздо короче и понятнее.

Какой вариант полиморфизма стоит использовать?
Мы с вами рассмотрели три варианта реализации полиморфизма в Java:
  1. Наследование класса
  2. Наследование абстрактного класса
  3. Наследование интерфейса
Во всех ситуациях, где это возможно, старайтесь использовать интерфейсы. Наследование классов нужно свести к минимуму. Причина простая – класс может наследовать несколько интерфейсов, но только один класс или абстрактный класс. Поэтому интерфейсы стратегически являются более выгодным решением.

Проблема с наследованием еще и в том, что нарушается инкапсуляция (если вы не понимаете почему, перечитайте этот блок). В некоторых ситуациях это допустимо. В других же может привести к проблемам с поддерживаемостью кода (изменение в родительском классе может сломать много наследников).
Enum
В java существует возможность создать набор из ограниченного числа значений. По сути это константы, которые нужны, чтобы сделать код более читаемым и ограничить возможные значения в передаваемых аргументах. Но в отличие от некоторых других языков программирования, где enum – это просто числовая константа, в Java это полноценный класс, у которого могут быть методы и поля. А ещё enum также может реализовывать интерфейсы.
Пример реализации типов транспортного средства с помощью enum:
public enum VehicleType {
  CAR,
  SHIP
}

public class Vehicle {

  public void move(VehicleType vehicleType) {
    switch (vehicleType) {
      case CAR:
        System.out.println("Driving");
        break;
      case SHIP:
        System.out.println("Sailing");
        break;
    }
  }
}
В приведённом примере в качестве аргумента метода move возможно передать только корректное значение.
Примитивы и объекты
В Java помимо классов существуют так называемые примитивы - определённые типы данных:
  • byte – 8-битовое целое число со знаком. Может принимать значение от -128 до 127.
  • short – 16-битовое целое число со знаком. Может принимать значение от -32768 до 32767.
  • int – 32-битовое целое число со знаком. Может принимать значение от -2^31 до 2^31 - 1. Начиная с Java 8, тип int может быть без знака, тогда значение может быть от 0 до 2^32 - 1.
  • long – 64-битовое целое число. Число со знаком может принимать значение от -2^63 до 2^63 - 1. Начиная с Java 8 тип long может быть без знака, тогда значение может быть от 0 до 2^64 - 1.
  • float – 32-битовое число с плавающей запятой.
  • double – 64-битовое число с плавающей запятой.
  • boolean – логический тип данных, может иметь только 2 значения, true или false.
  • char – один символ в формате Unicode.
Для каждого примитива в Java существует соответствующий класс-обёртка: Byte, Short, Integer, Long, Float, Double, Boolean, Char. Каждый такой класс содержит одно значение соответствующего типа.

"Под капотом" Java выполняет конвертацию между примитивом и классом-обёрткой, если реальный тип не совпадает с объявленным:
Integer x=1;          // autoboxing
int y=new Integer(1); // unboxing
Процесс конвертации примитива в класс называется autoboxing, а обратный процесс называется unboxing. Примитивы работают быстрее, к тому же при их использовании расходуется меньше оперативной памяти. Однако примитивы нельзя использовать в дженериках, а также в коллекциях и Reflection API.
Значение null
Стоит упомянуть важный концепт в Java – null. Это особое значение, которое может быть присвоено любому объекту (примитивы не могут быть null). Значение null используется в Java для того, чтобы записать отсутствующие данные какого-либо типа. Посмотрите на пример класса Person ниже.
public class Person {
  private final String firstName;
  private final String lastName;
  private final String patronymic;

  /* конструктор */
}
Отчество (поле patronymic) у некоторых людей может отсутствовать. Что в этом случае записывать? Один из выходов – использовать null. Посмотрите на пример кода ниже.
public class Main {
    public static void main(String[] args) {
        Person person = new Person("Петр", "Иванов", null);
        System.out.println(person);
    }
}
Значение null не имеет конкретного типа, и его можно передавать в качестве любого объекта. Тем не менее, с использованием null связаны определенные проблемы. Посмотрите на пример кода с классом Airplane ниже.
public class Main {
    public static void main(String[] args) {
        Airplane airplane = new Airplane(1980, "TU-154");
        airplane = null;
        airplane.print();
    }
}
Если вы запустите этот код, то вместо привычного вывода инфорации о самолете в консоль увидите следующее сообщение:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "org.example.Airplane.print()" because "airplane" is null
	at org.example.Main.main(Main.java:8)
Ошибка произошла в строчке вызова airplane.print(). Дело в том, что null не является объектом сам по себе. Это лишь представление для отсутствующего значения. Следовательно, у null не может быть ни метода print, ни какого-либо другого. Поэтому попытка такого обращения приводит к исключению – NullPointerException.

Про исключения и способы их обработки мы поговорим далее.

Если вы используете null в своей программе, вам следует иметь это ввиду.
Класс Object
Наверняка вы обратили внимание, что при попытке обращения к методу какого-либо класса Idea подсказывает те, которые мы не добавляли: equals, hashCode, toString и так далее. Дело в том, что все классы в Java являются наследниками базового класса Object. Если вы не писали extends в объявлении класса, то Java подставляет extends Object автоматически.

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

Класс Object предоставляет ряд методов, которые повсеместно используются в Java. Но мы обсудим лишь наиболее важные из них:
  • equals
  • hashCode
  • toString
Метод equals
Сигнатура метода equals у Object выглядит следующим образом.
class Object {
    /* other methods... */
  
    public boolean equals(Object obj) {
        return (this == obj);
    }
}
Он служит для сравнения двух объектов между собой. Например, если airplane1.equals(airplane2), то airplane1 равен airplane2. Но по умолчанию метод сравнивает не содержимое объектов, а их ссылки: это то, что делает оператор ==. Если this и obj указывают на один и тот же объект в heap, то возвращается true. Иначе – false.

Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        Airplane airplane1 = new Airplane(1980, "RED");
        Airplane airplane2 = airplane1;
        System.out.println(airplane1.equals(airplane2));
    }
}
В консоль выведется true, потому что переменные airplane1 и airplane2 указывают на один и тот же объект в heap.
Посмотрите на еще один пример кода ниже:
public class Main {
    public static void main(String[] args) {
        Airplane airplane1 = new Airplane(1980, "RED");
        Airplane airplane2 = new Airplane(1980, "RED");
        System.out.println(airplane1.equals(airplane2));
    }
}
Здесь результат уже будет false.
Потому что переменные airplane1 и airplane2 указывают на два разных объекта в heap.

Принцип понятен, но такое поведение не выглядит логичным. В конце концов, если у нас есть два объекта Airplane с одинаковым набором полей, то метод equals должен возвращать true, ведь в этом его суть. К счастью, это можно исправить. Как вы уже догадались, на помощь снова приходит наследование и полиморфизм. Поскольку метод equals определен в дочернем классе Object, мы можем переопределить его в Airplane. Посмотрите на пример кода ниже.
public class Airplane {
  /* поля и конструктор */ 
  
  @Override
  public boolean equals(Object o) {
    if (o instanceof Airplane airplane) {
      return year == airplane.year && Objects.equals(color, airplane.color);
    }
    return false;
  }
}
Поскольку метод equals принимает в качестве параметра Object, то объект может не быть экземпляром класса Airplane. Помните про то, что любой объект в Java наследует Object. Следовательно, Airplane точно является Object, но не каждый Object Airplane.

Оператор instanceof служит для проверки того, является ли переменная экземпляром или наследником указанного класса (в нашем случае, Airplane). Если условие выполняется, то в переменной airplane будет представлен объект, приведенный к классу Airplane.

Теперь нужно реализовать нашу кастомную логику equals. Нас интересует сравнение полей year и color. Поле year мы сравниваем через оператор ==, потому что year – примитив. Поскольку их значения копируются при передаче, то и сравниваются именно они, а не ссылки.

Для проверки равенства color мы используем утилитный метод из класса Objects. Его реализация представлена ниже:
public class Objects {
    public static boolean equals(Object a, Object b) {
        return (a == b) || (a != null && a.equals(b));
    }
}
Сначала два объекта сравниваются на ссылочное равенство через ==. Далее, если объект a не равен null, то мы просто делегируем проверку на equals уже ему. Поскольку в классе String метод equals уже реализован правильно, нам не нужно об этом беспокоиться.

Мы обсудим концепцию null далее в курсе. Для текущего понимания – это специальное значение, которое отображает "ничто". Может быть присвоено любому объекту.

Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        Airplane airplane1 = new Airplane(1980, "TU-154");
        Airplane airplane2 = new Airplane(1980, "TU-154");
        System.out.println(airplane1.equals(airplane2));
    }
}
Если вы все сделали правильно, то увидите true в консоли.
Метод hashCode
Метод hashCode неразрывно связан с equals. Для соблюдения корректного контракта реализации, их всегда нужно переопределять вместе.

Контракт можно описать следующими пунктами:
  1. Если объекты равны по equals, их hashCode'ы должны быть равны.
  2. Если объекты НЕ равны по equals, их hashCode'ы могут как отличаться, так и быть равны.
  3. Если объекты равны по hashCode'у, они могут быть как равны, так и не равны по equals.
Если вы поищите метод hashCode в классе Object, то найдете такую строчку:
public class Object {
    public native int hashCode();
}
Это значит, что реализации hashCode спрятана внутри Java Virtual Machine, а не в виде обычного Java-кода. Реализации зависит от JDK, которую вы используете. Один из вариантов – это указатель на адрес, где аллоцирован объект, преобразованный в int.

Чтобы посчитать hashCode для Airplane, мы воспользуемся решением, схожим с подсчетом equals. То есть посчитаем hashCode для каждого поля и преобразуем значения в единый результат. Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        Airplane airplane1 = new Airplane(1980, "TU-154");
        Airplane airplane2 = new Airplane(1980, "TU-154");
        System.out.println("HashCode для Airplane1: " + airplane1.hashCode());
        System.out.println("HashCode для Airplane2: " + airplane2.hashCode());
    }
}
Если вы все сделали правильно, то значения hashCode-ов для этих двух объектов будут совпадать:
HashCode для Airplane1: -1810167607
HashCode для Airplane2: -1810167607
И equals, и hashCode можно переопределить с помощью Idea: нет необходимости писать их руками. Для этого нажмите на класс правой кнопкой мыши и выберите пункт: Generate -> equals/hashCode
HashMap
Класс HashMap является каноничным примером необходимости переопределения equals и hashCode. Про алгоритм работы этой структуры данных можете почитать здесь.

Идея структуры данных в том, чтобы хранить ассоциативные массивы. Иначе говоря, это отношения ключ-значение, где и ключ, и значение, могут быть любого типа. Посмотрите на пример кода ниже.
import java.util.HashMap;
import java.util.Map;

public class Main {
    public static void main(String[] args) {
        Airplane airplane1 = new Airplane(1980, "TU-154");
        Airplane airplane2 = new Airplane(1990, "Boeing");
        Map<Airplane, Integer> map = new HashMap<>();
        map.put(airplane1, 2);
        map.put(airplane2, 3);
  }
}
Конструкция <Airplane, Integer> - это реализация механизма generic-типов в Java. Мы обсудим это подробнее позже. Пока можете считать, что это способ типизации: в map можно положить ключ лишь типа Airplane и получить значение Integer.

Класс HashMap реализует интерфейс Map. Так что мы можем присвоить HashMap в Map.

В данном примере мы добавили в Map два отношения: airplane1 -> 2 и airplane -> 3. Также мы можем искать значения по ключам с помощью метода get. Интерес в том, что средняя сложность поиска по ключу в HashMapO(1). Достигается это за счет правильной реализации equals и hashCode (подробности читайте по ссылке выше).

Посмотрите на пример кода с поиском ключа в HashMap ниже.
public class Main {
    public static void main(String[] args) {
        Airplane airplane1 = new Airplane(1980, "TU-154");
        Airplane airplane2 = new Airplane(1990, "Boeing");
        Map<Airplane, Integer> map = new HashMap<>();
        map.put(airplane1, 2);
        map.put(airplane2, 3);

        System.out.println(map.get(new Airplane(1980, "TU-154")));
        System.out.println(map.get(new Airplane(1990, "Boeing")));
  }
}
В качестве значений в консоли мы получим 2 и 3. Несмотря на то, что в качестве параметра метода get мы передали новые объекты Airplane, поля у них те же, что и у ключей на момент вставки. А значит, что корректное переопределение equals и hashCode позволяет найти нам те значения, которые мы ожидаем получить.
Метод toString
Смысл метода toString полностью соответствует его названию: приведение содержимого объекта к строке.
Посмотрите на реализацию toString в классе Object:
public class Object {
    public String toString() {
        return getClass().getName() + "@" + Integer.toHexString(hashCode());
    }
}
По умолчанию вызов toString вернет конкатенацию из значений:
  1. Название класса
  2. Символ @
  3. Значение hashCode, приведенное к строке в шестнадцатеричном виде.
В большинстве ситуаций полученное значение не будет информативным. Давайте переопределим метод toString в классе Car. Посмотрите на пример кода ниже:
public class Car implements Vehicle {
    /* Поля и конструктор */
  
    @Override
    public String toString() {
        return String.format("Car[brand=%s, year=%d, color=%s]", this.brand, this.year, this.color);
    }
}
Мы возвращаем информацию о полях Car в виде строки.

Метод String.format имеет такой же принцип работы, как и функция printf в языке C. То есть позволяет в удобном виде сконкатенировать несколько значений в одну строку.

У функции toString есть еще одно важное свойство: Java вызывает ее автоматически, когда вы конкатенируете объект со строкой. Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        Car car = new Car("Toyota", 2009, "WHITE");

        System.out.println("Машина: " + car.toString());
        System.out.println("Машина: " + car);
    }
}
Первый и второй вывод в консоль дадут одинаковые результаты. Во втором случае Java автоматически вызовет метод toString у Car, чтоб сконкатенировать результат со строкой Машина: .

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

Сгенерировать toString можно также с помощью Idea.
Исключения
В любой программе возникают моменты, когда нам нужно обрабатывать исключительные ситуации. Вернемся к классу Airplane. Допустим, мы не хотим, чтобы поле brand приобретало значение null: в этом случае нужно прервать создание экземпляра класса Airplane. Как нам добиться этого эффекта? На помощь нам придут исключения.

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

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

Тем не менее, наследоваться напрямую от Throwable считается плохим тоном. В языке Java уже есть наследники от Throwable, которые мы будем применять:
  1. Exception – проверяемое исключение, наследуется от Throwable.
  2. RuntimeException – непроверяемое исключение, наследуется от Exception.
  3. Error – ошибка, которая означает серьезную проблему в программе. Например, переполнения стека вызовов (StackOverflowError), или недостаток памяти (OutOfMemoryError). Наследуется от Throwable.
Разницу между проверяемыми и непроверяемыми исключениями мы обсудим позже. Пока давайте остановимся на RuntimeException.
Выбрасывание и обработка исключений
Мы начали абзац с того, что мы не хотим допускать ситуации, когда поле brand у класса Airplane равно null. Посмотрите на пример кода ниже:
public class Airplane implements Vehicle {
    /* поля и конструктор */

    public Airplane(int year, String color) {
        if (color == null) {
            throw new RuntimeException("Color cannot be null");
        }
        this.year = year;
        this.color = color;
    }
}
Если мы пытаемся создать экземпляр Airplane с полем color, равным null, то выбрасывается RuntimeException. Давайте попробуем это сделать в main и посмотрим, что получится:
public class Main {
    public static void main(String[] args) {
        Airplane airplane = new Airplane(1980, null);
        airplane.print();
    }
}
При запуске вы увидите такое сообщение в консоли:
Exception in thread "main" java.lang.RuntimeException: Color cannot be null
	at org.example.Airplane.<init>(Airplane.java:12)
	at org.example.Main.main(Main.java:9)
Программа завершилась с ошибкой на той точке, где мы попытались создать Airplane с неожиданным значением поля brand.

Такое поведение логично, но зачастую не является желанным. В конце концов, если программа будет завершаться аварийно при каждой недопустимой ситуации, ценность ее под вопросом. Поэтому помимо создания исключительных ситуаций, в Java еще есть механизм их обработки. Посмотрите на пример кода ниже, где мы "ловим" исключение:
public class Main {
    public static void main(String[] args) {
        try {
            Airplane airplane = new Airplane(1980, null);
            airplane.print();
        } catch (RuntimeException e) {
            System.out.println("Ошибка при создании Airplane");
        }
    }
}
Конструкция try-catch позволяет обрабатывать определенные типы исключений и перенаправлять поток выполнения программы. В данном случае, если создание экземпляра Airplane происходит без ошибок, то код успешно доходит до строчки airplane.print(). В иной же ситуации срабатывает блок catch, и выполнение переходит туда. Если вы запустите этот фрагмент, то увидите в консоли сообщение: Ошибка при создании Airplane.

Можно добавлять несколько блоков catch под разные виды исключений.

Также мы хотим напомнить про механизм наследования в Java. Исключения – это тоже классы. При этом RuntimeException наследует Exception. Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        try {
            Airplane airplane = new Airplane(1980, null);
            airplane.print();
        } catch (Exception e) {
            System.out.println("Ошибка при создании Airplane");
        }
    }
}
Здесь в блоке catch мы ловим Exception вместо RuntimeException. Но поскольку RuntimeException наследуется от Exception, этот фрагмент кода работает точно так же, как и предыдущий. Отсюда следует вывод, что можно ловить не только сам класс исключения, но и его предков.

Исключения можно использовать не только в конструкторах, но и в любых методах Java. Посмотрите на пример кода ниже. В нем мы не даем создать новую копию Airplane через withYear, если переданное значение меньше нуля:
public class Airplane implements Vehicle {
    /* поля и конструктор */
  
    public Airplane withYear(int newYear) {
        if (newYear < 0) {
            throw new RuntimeException("Year cannot be less than zero: " + newYear);
        }
        return new Airplane(newYear, this.color);
    }
}
Проверяемые исключения
Класс Exception олицетворяет проверяемые исключения. Они работают точно так же, как и RuntimeException, но с той разницей, что для выбрасывания Exception из метода нам нужно явно прописать его в сигнатуре. Посмотрите на пример кода ниже с методом Airplane.withYear.
public class Airplane implements Vehicle {
    /* поля и конструктор */
  
    public Airplane withYear(int newYear) {
        if (newYear < 0) {
            throw new Exception("Year cannot be less than zero: " + newYear);
        }
        return new Airplane(newYear, this.color);
    }
}
Удивительно, но этот фрагмент не компилируется. Чтобы выбрасывать проверяемые исключения, мы должны явно сообщить об этом в сигнатуре метода. Посмотрите на исправленный вариант ниже:
public class Airplane implements Vehicle {
    /* поля и конструктор */
  
    public Airplane withYear(int newYear) throws Exception {
        if (newYear < 0) {
            throw new Exception("Year cannot be less than zero: " + newYear);
        }
        return new Airplane(newYear, this.color);
    }
}
Рядом с названием метода мы добавили throws Exception. Может показаться, что эта деталь незначительна. Однако давайте попробуем вызвать Airplane.withYear в функции main. Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        Airplane airplane = new Airplane(1980, "RED");
        Airplane newAirplane = airplane.withYear(2000);
        newAirplane.print();
    }
}
Сюрприз, но это фрагмент кода тоже не компилируется! Дело в том, что когда мы обращаемся к методу, у которого в сигнатуре есть throws с проверяемым исключением, то компилятор обязывает нас выполнить одно из следующих действий:
  • Отловить исключение с помощью catch.
  • Пробросить его дальше, указав уже в текущем методе throws ...
Посмотрите на исправленный вариант кода ниже:
public class Main {
    public static void main(String[] args) {
        Airplane airplane = new Airplane(1980, "RED");
        try {
            Airplane newAirplane = airplane.withYear(2000);
            newAirplane.print();
        } catch (Exception e) {
            System.out.println("Ошибка при создании нового Airplane");
        }
    }
}
Проще говоря, проверяемые исключения принуждают нас либо обработать их на месте, либо пробросить дальше, чтобы их отловил кто-то, кто вызвал нашу функцию.

Хотя идея с проверяемыми исключениями выглядит привлекательно на первый взгляд, в современной разработке они практически не используются. Причины этого станут понятны в следующем семестре, когда мы начнем изучение Spring Framework. Но если вам интересен этот вопрос, можете ознакомиться со следующей статьей.
Кастомные типы исключений
Выбрасывать и отлавливать RuntimeException или Exception считается не очень хорошим подходом. Вместо этого лучше бросать наследника, который покажет конкретный тип ошибки. Если вы откроете декларацию класса RuntimeException в Idea и нажмете на стрелку слева от названия класса, то увидите список наследников: они предоставляются стандартной библиотекой Java. Конкретно в нашем случае стоит использовать IllegalArgumentException вместо RuntimeException. Также вы можете создать свой класс, отнаследовать его от RuntimeException и бросать/отлавливать его как собственный тип исключения.

Отлавливать Exception или RuntimeException вместо конкретного типа исключения плохо по той причине, что вы можете "поймать" не только ту ошибку, на которую рассчитывали, но и другую, которая возникла без вашего ведома. Обычно это означает, что в коде есть какая-то исключительная ситуация, которую вы не обработали. Поэтому мы рекомендуем вам избегать таких подходов.

Помните про null? Если вы присвоили это значение в переменную какого-то типа, а затем пытаетесь обратиться к методу, то Java выбрасывает NullPointerException – наследника RuntimeException.
Тип Error
У класса Throwable два прямых наследника: Exception и Error. С первым мы разобрались, а второй требует дополнительных разъяснений. С точки зрения кода Error ведет себя как обычный RuntimeException. Но у этого типа есть один нюанс. Наследники Error характеризуют ошибки, которые вы как программист не сможете обработать в коде. Например, переполнения стека вызовов (StackOverflowError) или невозможность создать новый объект в heap, потому что не хватает памяти (OutOfMemoryError). Иначе говоря, это ошибки не прикладного кода, а самой JVM. Подход следующий: если возникает Error, мы не отлавливаем его, а просто даем программе упасть. Звучит контринтуитивно, но в этом есть логика. Например, какие будут ваши действия в случае OutOfMemoryError? Java напрямую вам говорит: "У меня закончилась память, чтобы создавать новые объекты". Поскольку Java – это язык со сборщиком мусора, где нельзя вручную (как в C/C++) выделять и освобождать память, такая ситуация окажется для вас патовой. Решения никакого нет, следовательно, лучше дать программе упасть, чтобы потом разобраться с причинами.

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

Здесь нужно сделать еще один очень важный вывод. Никогда не отлавливайте Throwable в блоке try-catch. Потому что Throwable является предком для класса Error. Следовательно, в catch попадут ошибки, которые не должны быть обработаны.
StackTrace
Еще одно важное понятие исключений в Java – StackTrace. Это стек вызовов, который показывает, в каких классах и на каких строчках кода произошла ошибка. Давай посмотрим еще раз на пример с выбрасыванием исключения в конструкторе Airplane и последующим аварийным заверешением программы в main.
public class Airplane implements Vehicle {
    /* поля и конструктор */

    public Airplane(int year, String color) {
        if (color == null) {
            throw new IllegalArgumentException("Color cannot be null");
        }
        this.year = year;
        this.color = color;
    }
}

public class Main {
   public static void main(String[] args) {
      Airplane airplane = new Airplane(1980, null);
      airplane.print();
   }
}
В консоли вы увидите следующее сообщение об ошибке:
Exception in thread "main" java.lang.RuntimeException: Color cannot be null
	at org.example.Airplane.<init>(Airplane.java:12)
	at org.example.Main.main(Main.java:6)
То есть исключение хранит информацию о том, в каком месте оно было выброшено: через какие строчки кода в этом случае оно прошло. Этот инструмент очень полезен, чтобы находить причины ошибок. Действительно, если бы получили просто сообщение Color cannot be null без каких-либо уточнений о том, где оно случилось, найти источник проблемы было бы очень непросто.

И здесь мы хотим сделать важную ремарку. Если с исключениями работать неправильно, возможна потеря stacktrace'а. Чтобы понять проблему, посмотрите на пример кода ниже:
public class Airplane implements Vehicle {
    /* поля и конструктор */

    public Airplane(int year, String color) {
        if (color == null) {
            throw new IllegalArgumentException("Color cannot be null");
        }
        this.year = year;
        this.color = color;
    }
}

public class Main {
   public static void main(String[] args) {
     try {
         Airplane airplane = new Airplane(1980, null);
         airplane.print();
     }
     catch (IllegalArgumentException e) {
         throw new IllegalStateException("Couldn't create Airplane");
     }
   }
}
В функции main мы отлавливаем возможное IllegalArgumentException и пробрасываем в этом случае IllegalStateException далее по стеку вызовов. Если вы запустите эту программу, то увидите следующую ошибку в консоли:
Exception in thread "main" java.lang.IllegalStateException: Couldn't create Airplane
	at org.example.Main.main(Main.java:11)
Информация о функции main присутствует. Тем не менее, мы потеряли сведения о классе Airplane, который и породил изначальное IllegalArgumentException. Чтобы избежать этой проблемы, достаточно вторым параметром конструктора IllegalStateException передать объект того исключения, который мы отловили изначально. Посмотрите на пример кода ниже:
public class Airplane implements Vehicle {
    /* поля и конструктор */

    public Airplane(int year, String color) {
        if (color == null) {
            throw new IllegalArgumentException("Color cannot be null");
        }
        this.year = year;
        this.color = color;
    }
}

public class Main {
   public static void main(String[] args) {
     try {
         Airplane airplane = new Airplane(1980, null);
         airplane.print();
     }
     catch (IllegalArgumentException e) {
         // в переменной 'e' хранится объект исключения, который был выброшен
         throw new IllegalStateException("Couldn't create Airplane", e); // передаем 'e' вторым параметром
     }
   }
}
Теперь вывод в консоли отличается:
Exception in thread "main" java.lang.IllegalStateException: Couldn't create Airplane
	at org.example.Main.main(Main.java:11)
Caused by: java.lang.IllegalArgumentException: Color cannot be null
	at org.example.Airplane.<init>(Airplane.java:12)
	at org.example.Main.main(Main.java:7)
Сразу видно, что IllegalStateException возникло из-за другого исключения – IllegalArgumentException. Можно посмотреть класс и метод, где оно было выброшено.

Механизм cause в исключениях позволяет выстраивать длинные цепочки причинно-следственных связей ошибок. Не стоит игнорировать их использование, потому что их наличие позволяет разбираться в проблемах намного проще и быстрее.
Дженерики
Дженерики – очень мощная концепция в Java, которая не только позволяет писать более безопасный с точки зрения типов код, но и избежать дублирования.
Тип Error
Чтобы понять необходимость дженериков, давайте рассмотрим простой пример. Предположим, что мы хотим создать контейнер для хранения объекта, который печатает свое содержимое в консоль, а также отдает содержимое наружу. Посмотрите на пример кода ниже:
public class Container {
    private final Car value;

    public Container(Car value) {
        this.value = value;
    }

    public boolean isPresent() {
        return value != null;
    }

    public void print() {
        if (isPresent()) {
            System.out.println("Container value is: " + value);
        } else {
            System.out.println("Container is empty");
        }
    }

    public Car getValue() {
        return value;
    }
}
Метод isPresent возвращает статус того, есть ли какое-то значение в Container-е. Метод print печатает содержимое в консоль в зависимости от статуса isPresent. А метод getValue возвращает само значение Car. Все работает хорошо, но что если мы хотим использовать Container и для других классов? Более того, не каждый из них даже может имплементировать интерфейс Vehicle. Например, в Container-е мы можем хранить строки, числа, другие кастомные объекты и так далее. Написание нового класса под каждый отдельный тип данных Container (ContainerCar, ContainerString и так далее) выглядит чрезмерно сложным и ненужным. Но мы же с вами помним, что все классы в Java наследуются от Object. Значит, в качестве value мы можем указать Object, и это даст нам возможность использовать один и тот же класс для хранения совершенно разных объектов! Посмотрите на пример кода ниже:
public class Container {
    private final Object value;

    public Container(Object value) {
        this.value = value;
    }

    public boolean isPresent() {
        return value != null;
    }

    public void print() {
        if (isPresent()) {
            System.out.println("Container value is: " + value);
        } else {
            System.out.println("Container is empty");
        }
    }

    public Object getValue() {
        return value;
    }
}
Вроде как, мы решили проблему. Правда тот код, который будет использовать Container, сильно усложнится. Посмотрите на пример ниже:
public class Main {
    public static void main(String[] args) {
        Container container = new Container(new Car(...));
        /* некоторая логика работы */
        Car car = (Car) container.getValue();
    }
}
Обратите внимание на строчку, где мы получаем значение из Container. В качестве типа там хранится Object, но нам нужен Car, потому что именно его мы положили туда. Значит, нам приходится делать down cast (приведение предка к потомку).

В то время как up cast (приведение потомка к предку) всегда безопасен, down cast таковым не является. Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        String str = "abc";
        Object obj = (Object) str;
        LocalDate date = (LocalDate) obj;
    }
}
Сначала мы присваем строку в переменную str. Далее выполняем up cast к Object и присваиваем результат в переменную obj. Поскольку любой класс в Java наследуется от Object, эта операция безопасна. В конце концов, мы пытаемся привести Object с помощью каста к типу LocalDate.

Класс LocalDate не является предком String, то есть они никак не связаны. Что же произойдет в этом случае? Давайте запустим программу и посмотрим:
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.time.LocalDate (java.lang.String and java.time.LocalDate are in module java.base of loader 'bootstrap')
	at org.example.Main.main(Main.java:10)
Мы получили исключение ClassCastException. Отсюда можно сделать вывод, что использование Object в качестве значение не является безопасным, потому что может привести к неожиданным ошибкам.
Внедрение дженериков
Давайте немного перепишем класс Container. Посмотрите на код ниже:
public class Container<T> {
    private final T value;

    public Container(Object value) {
        this.value = value;
    }

    public boolean isPresent() {
        return value != null;
    }

    public void print() {
        if (isPresent()) {
            System.out.println("Container value is: " + value);
        } else {
            System.out.println("Container is empty");
        }
    }

    public T getValue() {
        return value;
    }
}
Значение T, которое мы указали в скобках возле название класса, – это и есть дженерик-параметр. Он означает, что при создании экземпляра этого класса мы явно указываем, какой тип хранится внутри. Поскольку он же и будет возращаться в getValue, мы избавляем себя от необходимости иметь дело с Object. Посмотрите на пример ниже с использованием Container<T>:
public class Main {
    public static void main(String[] args) {
        Container<Car> container = new Container<>(new Car(...));
        /* некоторая логика работы */
        Car car = container.getValue();
    }
}
Теперь container.getValue() сразу возвращает тип Car.

Возможно, вы обратили внимание, что мы создали Container с помощью new Container<>, а не new Container<Car>, хотя присваиваем значение именно в переменную типа Container<Car>. Начиная с Java 8 при создании объекта через new не обязательно указывать значения дженерик-параметров, если вы уже указали их в типе переменной.

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

Дженерики в Java – большая и сложная тема. Мы рассмотрели лишь основы, но также есть очень много нюансов, которые выходят за рамки курса. Но если вам интересно чуть глубже погрузиться в эту тему, ознакомьтесь с этой статьей.
Обертки вокруг примитивов
Возможно, вы уже обратили внимание, что помимо примитивов вроде int, double и boolean есть некие классы Integer, Double и Boolean. Это иммутабельные классы-обертки вокруг соответствующих примитивов.

Если вам непонятно значение слова "иммутабельный", вернитесь к параграфу про инкапсуляцию в ООП.

Они нужны для того, чтобы использовать их в дженериках. Потому что код вроде Container<int> не скомпилируется, а вот Container<Integer> – да. Также интересен механизм autoboxing, который построен на этих обертках в Java. Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        int value = getInt();
        printInt(value);
    }
  
    private static Integer getInt() {
        return 10;
    }
  
    private static void printInt(Integer value) {
        System.out.println(value);
    }
}
На первый взгляд вообще не понятно, как этот код компилируется. Посмотрите на метод getInt. Возвращаемое значение – Integer, а в return мы указываем примитив int. Причем Integer – это класс, а int – примитив. Как это может работать?

Дело в том, что Java знает об этих обертках и этом, каким примитивам они соответствуют. Поэтому выполняет преобразования от одного типа к другому автоматически. Чтобы было понятно, посмотрите на альтернативный вариант кода ниже. Он работает точно так же, но теперь все преобразования видны явно:
public class Main {
    public static void main(String[] args) {
        Integer valueObj = getInt();
        int value = valueObj.intValue();
        printInt(Integer.valueOf(value));
    }
  
    private static Integer getInt() {
        return Integer.valueOf(10);
    }
  
    private static void printInt(Integer value) {
        System.out.println(value);
    }
}
Проблемы NullPointerException при autoboxing
В момент autoboxing возможны NullPointerException. К примитивам нельзя присвоить null: будет ошибка компиляции. Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        printInt(null);
    }
  
    private static void printInt(int value) {
        System.out.println(value);
    }
}
Это код даже не скомпилируется. С другой стороны, другой пример кода скомпилируется без проблем, но завершится с NullPointerException:
public class Main {
    public static void main(String[] args) {
        Integer valueObj = null;
        printInt(valueObj);
    }
  
    private static void printInt(int value) {
        System.out.println(value);
    }
}
В момент передачи параметра типа Integer в функцию printInt произойдет попытка unboxing-а: преобразование объекта Integer к примитиву int (по сути у объекта типа Integer будет вызван метод intValue). И как раз в этот момент и произойдет NullPointerException. Запустите программу, и вы увидите такое сообщение об ошибке:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "valueObj" is null
	at org.example.Main.main(Main.java:9)
Коллекции
Ни один язык программирования не обходится без коллекций. Списки, множества, словари – все эти элементы являются минимальными строительными блоками, без которых построить большое и сложное приложение невозможно.
Архитектура Java Collections Framework
Мы уже обсудили с вами наследование и полиморфизм в Java. Как можно догадаться, коллекции не остались в стороне. Посмотрите на картинку ниже. На ней изображены интерфейсы коллекций Java в иерархии наследования.
Источник – https://www.javacodeexamples.com/java-collection-framework-tutorial-with-examples/1641

Синим цветом отмечены интерфейсы. Зеленым – реализации (то есть конкретные классы коллекций). Сначала мы обсудим наиболее важные интерфейсы, а затем перейдем к классам.
Iterable и Iterator
Это базовый интерфейс для любой коллекции в Java.

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

Он предоставляет метод iterator, который возвращает интерфейс Iterator<T>.

Все интерфейсы коллекций являются дженериками.

Объявление метода Iterator<T> выглядит следующим образом:
public interface Iterator<E> {
  boolean hasNext();
  
  E next();
  
  default void remove() {
    throw new UnsupportedOperationException("remove");
  }
  
  default void forEachRemaining(Consumer<? super E> action) {
    Objects.requireNonNull(action);
    while (hasNext())
      action.accept(next());
  }
}
Это олицетворение чего-то, по чему мы можем итерироваться. То есть запрашивать текущий объект и продвигаться к следующему, если он присутствует.

При получении Iterable<T> в качестве объекта по нему можно пройтись следующим образом:
public class IterableExample {
    public void iterate(Iterable<String> iterable) {
        Iterator<String> iterator = iterable.iterator();
        while (iterator.hasNext()) {
            String element = iterator.next();
            System.out.println("Current element: " + element);
        }
    }
}
Пока Iterator<String> отдает элементы, мы запрашиваем их в цикле while и печатаем в консоль. Хотя такой код и корректен, он выглядит не очень красиво. К счастью, в Java для этого есть синтаксический сахар. Посмотрите на пример ниже:
public class IterableExample {
    public void iterate(Iterable<String> iterable) {
        for (String element : iterable) {
            System.out.println("Current element: " + element);
        }
    }
}
Этот фрагмент код работает точно так же, как и предыдущий. Здесь можно сделать два важных вывода:
  • Если какой-то класс/интерфейс наследует Iterable<T>, то по нему можно пройтись в цикле for.
  • Все коллекции в Java наследует Iterable<T>, так что по ним можно итерироваться аналогично.
Collection
Интерфейс Collection расширяет Iterable. В отличие от Iterable, он олицетворяет собой какую-то коллекцию с конечным количество элементов. Некоторые методы, которые предоставляет Collection:
  1. int size(). Возвращает количество элементов в коллекции.
  2. boolean isEmpty(). Возвращает true, если коллекция пустая.
  3. boolean contains(Object o). Возвращает true, если коллекция содержит указанный элемент. Контракт интерфейса обязывает проверять наличие по equals.
  4. boolean add(E e). Добавляет элемент в коллекцию. Возвращает true, если размер коллекции действительно поменялся (этот принцип будет понятен дальше).
  5. boolean remove(Object o). Удаляет объект из коллекции.
  6. void clear(). Удаляет все элементы из коллекции.
Сам по себе Collection не очень интересен. Гораздо любопытнее его прямые потомки: List и Set. Рассмотрим их подробнее.

У Collection еще есть потомок Queue, который представляет собой обычную FIFO структуру данных. Мы не будем на ней останавливаться, так как она гораздо менее востребована в разработке, чем List и Set.
List
Интерфейс List представляет список с фиксированным порядком элементов. Некоторые методы из интерфейса:
  1. E get(int index). Возвращает элемент из списка по индексу начиная с 0.
  2. E set(int index, E element). Заменяет один элемент другим по указанному индексу.
  3. void add(int index, E element). Вставляет элемент в коллекцию на место указанного индекса.
  4. E remove(int index). Удаляет элемент по указанному индексу.
  5. int indexOf(Object o). Возвращает индекс указанного элемента.
Поскольку List оперирует понятиям индексов, то мы можем использовать классический for-i цикл для итерации по этой коллекции. Посмотрите на пример кода ниже:
public class ListExample {
    public void iterate(List<String> list) {
        for (int i = 0; i < list.size(); i++) {
            System.out.println("index = " + i);
            System.out.println("element = " + list.get(i));
        }
    }
}
Set
Интерфейс Set представляет множество. Отличия от списка следующие:
  1. В Set'е нет дубликатов: они удаляются автоматически. В реализации HashSet их поиск происходит по equals/hashCode.
  2. Индексы отсутствуют, потому что порядок элементов не определен.
Никаких новых методов, кроме тех, что уже есть в Collection, Set не предоставляет.

Помните, что метод Collection.add возвращает boolean? Так вот, если вы добавляете в set уже существующий элемент, то он вернет false, потому что дубликаты в Set не записываются и размер коллекции не изменился.
Map
Этот интерфейс, как вы заметили, стоит в стороне от Iterable. Мы уже упоминали Map в этом курсе. Он представляет собой ассоциативный массив. То есть комбинацию вида ключ-значение, где и ключ, и значение могут быть любыми типами. При этом сам интерфейс объявлен как дженерик Map<K, V>. А это значит, что операции над коллекцией безопасны по типам.

Вот некоторые методы, которые предоставляет интерфейс:
  1. int size(). Возвращает количество записанных пар ключ-значение.
  2. boolean isEmpty(). Возвращает true, если Map пустая.
  3. boolean containsKey(Object key). Возвращает true, если в Map присутствует указанный ключ.
  4. boolean containsValue(Object value). Возвращает true, если в Map присутствует указанное значение.
  5. V get(Object key). Возвращает значение по указанному ключу.
  6. V put(K key, V value). Записывает пару ключ-значение. Возвращает предыдущее значение, которое было под этим ключом, или null, если ключ до этого отсутствовал.
  7. V remove(Object key). Удаляет пару ключ-значение по переданному ключу. Возвращает значение, которое было удалено, или null, если по ключу ничего не было записано.
  8. void clear(). Удаляет все пары ключ-значение.
  9. Set<K> keySet(). Возвращает Set из всех ключей, которые записаны в Map.
  10. Collection<V> values(). Возвращает коллекцию из всех значений, которые записаны в Map.
  11. Set<Map.Entry<K, V>> entrySet(). Возвращает Set из всех пар ключ-значение.
Спрашивается, а как итерироваться по Map, если она не реализует интерфейс Iterable? Есть несколько вариантов:
Получение всех пар ключ-значение
public class MapExample {
    public void iterate(Map<String, Integer> map) {
        for (Map.Entry<K, V> entry : map.entrySet()) {
            System.out.printf("Key = %s, value = %d %n", entry.getKey(), entry.getValue()); 
        }
    }
}
Получение всех ключей
public class MapExample {
    public void iterate(Map<String, Integer> map) {
        for (String key : map.keySet()) {
            int value = map.get(key); 
            System.out.printf("Key = %s, value = %d %n", entry.getKey(), entry.getValue()); 
        }
    }
}
Вызов метода forEach
public class MapExample {
    public void iterate(Map<String, Integer> map) {
        map.forEach((k, v) -> System.out.printf("Key = %s, value = %d %n", k, v));
    }
}
Конкретные реализации
Мы рассмотрели с вами ключевые интерфейсы Java Collections Framework. Но что насчет реализаций? Ведь интерфейсы - это всего лишь типы без конкретной логики. Теперь давайте поговорим о классах:

ArrayList
Класс реализует интерфейс List. Данные хранятся внутри в виде обычного массива. Когда размер ArrayList приближается к размеру массива, то внутри происходит замена: создается новый массив большего размера, и в него копируются уже существующие данные.

В Java, как в C/C++, размер массива задается при создании и не может меняться. Мы поговорим об этом позже.

Пример использования ArrayList:
public class Main {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(1);
        System.out.println(list);
    }
}
В консоли вы увидите [1, 2, 1].

HashSet
Класс реализует интерфейс Set. Дубликаты устраняются на основании equals/hashCode элементов. Так что объекты, которые вы отправляете в HashSet, должны корректно реализовывать эти методы.
Пример использования HashSet:
public class Main {
    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        set.add(1);
        set.add(2);
        set.add(1);
        System.out.println(set);
    }
}
Поскольку порядок элементов в Set не определен, результат вывода может разниться. Тем не менее, вы должны увидеть комбинацию из чисел 1 и 2 (то есть без дубликатов).

HashMap
Класс реализует интерфейс Map. Для корректной работы ключи должны правильно реализовывать equals/hashCode. При этом значениям реализовывать эти методы необязательно.

Пример использования HashMap:
public class Main {
    public static void main(String[] args) {
        Map<Integer, String> map = new HashMap<>();
        map.put(1, "Bob");
        map.put(2, "Alice");
        System.out.println(map);
    }
}
В консоли вы увидите {1=Bob, 2=Alice}. Порядок также может отличаться.

У HashMap еще имеется наследник LinkedHashMap. Работает точно так же, только элементы гарантировано хранятся в том порядке, в каком их добавили. Аналогично у HashSet есть наследник LinkedHashSet.
Пара слов о массивах
Как и в C/C++, в Java есть массивы. Их можно создать от абсолютно любого типа (в том числе примитива). Посмотрите на пример использования массивов ниже:
public class Main {
  public static void main(String[] args) {
      String[] arr = new String[20];
      for (int i = 0; i < arr.length; i++) {
          arr[i] = "Str" + i;
      }
  }
}
Как бы то ни было, мы рекомендуем вместо них использовать коллекции. Массивы – низкоуровневая структура данных. К тому же, у них фиксированный размер, который нельзя изменить. Коллекции же в этом плане более гибкие. А значит, код также становится более простым и понятным.

У массиов еще есть нюанс, связанный с дженериками. Подробнее об этом можете почитать здесь.
Enum
Необходимость в Enum
Enum (или перечисления) – еще один удобный концепт в Java, позволяющий писать более понятный и поддерживаемый код. Чтобы понять его преимущества, давайте вернемся к определению класса Airplane:
public class Airplane {
    private final int year;
    private final String color;

    public Airplane(int year, String color) {
        this.year = year;
        this.color = color;
    }
    
    /* другие методы */
}
Давайте подумаем: может ли быть самолет абсолютно любого цвета в нашем приложении? Скорее всего, ответ – нет. Наверняка есть допустимый набор цветов, которые мы считаем правильными. Один из способов решения проблемы – использование констант. Посмотрите на пример кода ниже:
public class Color {
    public static final String WHITE = "WHITE";
    public static final String BLUE = "BLUE";
}
В Java нет ключевого слова для определения констант, так что этой цели служит комбинация static final.

Теперь мы можем ссылаться на конкретные цвета при создании экземпляров класса Airplane следующим образом:
public class Main {
    public static void main(String[] args) {
        Airplane airplane = new Airplane(1985, Color.WHITE);
        /* действия с airplane... */
    }
}
Проблема решена, не так ли? Не совсем. Хотя мы и зафиксировали список цветов в классе Color, конструктор Airplane по-прежнему принимает String в качестве параметра. А значит, что технически мы не обязаны передавать одну из констант Color, а можем указать любую другую строку. Посмотрите на пример кода ниже:
public class Main {
    public static void main(String[] args) {
        Airplane airplane1 = new Airplane(1985, Color.WHITE);
        Airplane airplane2 = new Airplane(1985, "white");
        System.out.println(airplane1.equals(airplane2));
    }
}
Предполагаем, что мы корректно определили equals/hashCode для Airplane по примеру прошлых модулей.

В консоли мы получим результат false. Почему? Ведь в обоих случаях цвета одинаковые. Вот только в Color.WHITE записана строка "WHITE", а при создании airplane2 мы передаем "white". Технически эти строки не равны по equals, следовательно, объекты airplane1 и airplane2 также не равны.

Как вы понимаете, вместо "white" мы могли передать и другую строку. Например, "not valid color".
Разбираемся с Enum
Давайте вместо констант объявим enum. Посмотрите на пример кода ниже:
public enum Color {
    WHITE, BLUE;
}
На первый взгляд может показаться, что enum – это какой-то специальный тип как class или interface. На самом же деле, enum – это обычный класс. А такое объявление является синтаксическим сахаром.

Чтобы суть enum стала понятнее, посмотрите на пример кода ниже. Это то, чем на самом деле является enum Color.
public class Color extends Enum<Color> {
    public static final Color WHITE = new Color("WHITE", 0);
    public static final Color BLUE = new Color("BLUE", 1);
    
    private Color(String name, int ordinal) {
        super(name, ordinal);
    }
    
    /* другие методы из класса Enum */
}
Все enum'ы в Java наследуют абстрактный класс Enum, в качестве дженерика которого передается текущий же класс. Каждое значение enum'а – это константа с типом данного класса. Конструктор enum'а приватный, чтобы в runtime не было возможности добавлять новые значения. На вход он принимает два параметра:
  1. Название enum'а. Его можно получить во время выполнения программы, вызвав у значения enum метод name().
  2. Порядковый номер enum'а. Считается в зависимости от того, как вы расположили enum'ы друг за другом.
Важное уточнение. Пример с классом enum – это лишь иллюстрация того, во что в итоге превращается тип enum. Писать так не нужно. Используйте обычное определение public enum Color {...}

Уже здесь мы можем сделать два важных вывода:
  1. Enum'ы не могут наследовать классы, потому что у каждого неявно есть предок Enum<T>.
  2. Enum'ы – это обычные классы. Значит, они могут содержать поля и реализовывать интерфейсы.
Второй пункт является killer feature Java. Во многих языка есть enum-ы, но обычно они превращаются в тип int при компиляции. С одной стороны, это избавляет от накладных расходов при создании объектов, с другой же, ограничивает функциональность. Java воспринимает enum-ы как обычные классы, и мы можем использовать это себе на благо.

Посмотрите на пример того, как в полях enum-а можно хранить значения:
public enum Color {
    WHITE("белый"), BLUE("синий");
  
    private final String ru;

    Color(String ru) {
        this.ru = ru;
    }

    public String getRu() {
        return ru;
    }
}
Все enum'ы в Java наследуют абстрактный класс Enum, в качестве дженерика которого передается текущий же класс. Каждое значение enum'а – это константа с типом данного класса. Конструктор enum'а приватный, чтобы в runtime не было возможности добавлять новые значения. На вход он принимает два параметра:
  1. Название enum'а. Его можно получить во время выполнения программы, вызвав у значения enum метод name().
  2. Порядковый номер enum'а. Считается в зависимости от того, как вы расположили enum'ы друг за другом.
Важное уточнение. Пример с классом enum – это лишь иллюстрация того, во что в итоге превращается тип enum. Писать так не нужно. Используйте обычное определение public enum Color {...}

Уже здесь мы можем сделать два важных вывода:
  1. Enum'ы не могут наследовать классы, потому что у каждого неявно есть предок Enum<T>.
  2. Enum'ы – это обычные классы. Значит, они могут содержать поля и реализовывать интерфейсы.
Второй пункт является killer feature Java. Во многих языка есть enum-ы, но обычно они превращаются в тип int при компиляции. С одной стороны, это избавляет от накладных расходов при создании объектов, с другой же, ограничивает функциональность. Java воспринимает enum-ы как обычные классы, и мы можем использовать это себе на благо.

Посмотрите на пример того, как в полях enum-а можно хранить значения:
public enum Color {
    WHITE("белый"), BLUE("синий");
  
    private final String ru;

    Color(String ru) {
        this.ru = ru;
    }

    public String getRu() {
        return ru;
    }
}
Каждое значение enum'а (иначе говоря, каждый экземпляр класса Color) хранит название текущего цвета на русском языке.

Скобки возле значения enum'а – это вызов конструктора, который мы объявили ниже.

Также мы добавили геттер getRu. А это значит, что конструкция Color.WHITE.getRu() будет валидна. Посмотрите на пример кода ниже, где мы перебираем все значения enum'а:
public class Main {
    public static void main(String[] args) {
        for (Color color : Color.values()) {
            System.out.println(color.getRu());
        }
    }
}
Статический метод .values() автоматически генерируется для каждого enum-а и возвращает массив всех значений, которые там есть. Если в процессе работы над кодом мы добавим новое значение, метод также будет его возвращать.

Нужно отметить, что хотя enum'ы и позволяют хранить значения в полях, важно следить за тем, чтобы они были иммутабельны. Поскольку каждое значение enum существует в программе в единственном экземпляре, одно изменение поля повлечет то, что все участки кода также это увидят.
Внедрение enum
Давайте перепишем класс Airplane так, чтобы поле color было enum.
public class Airplane {
    private final int year;
    private final Color color;

    public Airplane(int year, String color) {
        this.year = year;
        this.color = color;
    }
    
    /* другие методы */
}
Теперь попробуем создать экземпляр Airplane:
public class Main {
    public static void main(String[] args) {
        Airplane airplane = new Airplane(1985, Color.WHITE);
        /* действия с airplane... */
    }
}
Визуально кажется, что ничего не изменилось относительно примера с константами. Но посмотрите, что будет, если мы попытаемся создать экземпляр Airplane, передав в качестве Color строку.
public class Main {
    public static void main(String[] args) {
        Airplane airplane1 = new Airplane(1985, Color.WHITE);
        Airplane airplane2 = new Airplane(1985, "white"); // COMPILE ERROR!
        System.out.println(airplane1.equals(airplane2));
    }
}
При попытке передачи строки туда, где ожидался enum, мы получим ошибку компиляции. То есть использование enum гарантирует, что мы сможем передать туда лишь одно из его значений или null.

Каждый раз, когда у вас возникает желание добавить константы, подумайте, можно ли вместо этого внедрить enum?
Сравнение enum по ==
Есть один важный момент с enum, о котором вы уже могли догадаться. Несмотря на то, что enum – это класс, каждое его значение создается в единственном экземпляре и используется по всей программе. А это значит, что сравнение Color.WHITE == Color.WHITE даст true точно так же, как и Color.WHITE.equals(Color.WHITE).

Как бы то ни было, мы рекомендуем всегда использовать equals, чтобы код выглядел более единообразно.
Не возвращайте null
Ранее в курсе мы упоминали null как концепцию отсутствующего значения. Это знание может дать соблазн использовать его там, где не следует. Например, посмотрите на пример кода ниже. Это представление некоторого хранилища, в котором мы ищем Customer по id.
public class CustomerRepository {
    /* поля и конструктор */
  
    public Customer findById(CustomerId id) {
        if (!existsById(id)) {
            return null;
        }
        /* логика поиска Customer по id */
    }
}
Если сущность Customer отсутствует по id, то мы возращаем null. Иначе – запускаем иную логику его поиска.
Кажется, что все правильно. Но null может сыграть с нами злую шутку. Посмотрите на пример кода ниже:
public class Main {

  public static void main(String[] args) {
    CustomerRepository customerRepository = createCustomerRepository();
    Customer customer = customerRepository.findById(new CustomerId(1));
    System.out.println(customer.getSalary());
  }

  private static CustomerRepository createCustomerRepository() {
    /* логика создания CustomerRepository */
  }
}
Если CustomerRepository вернет null, то вызов customer.getSalary() приведет к NullPointerException. Это очень известная проблема в Java. Чтобы избежать ее, необходимо не возвращать null в публичных методах. Что же делать в тех ситуациях, когда значение отсутствует (мы не смогли найти Customer по переданному CustomerId)? Есть несколько вариантов:

Кидайте exception
Самый простой и очевидный способ. Даже если вы его не отловите, то по крайней мере в информации об исключении будет понятное сообщение об ошибке. NullPointerException же обычно не несет в себе никаких полезных сведений.

Используйте паттерн Null Object
Он означает, что вместо null мы возвращаем такой экземпляр Customer (или его наследника), который олицетворяет то, что значение отсутствует. Например, можно вернуть экземпляр, где все поля String заполнены пустыми строками.
Такой способ не всегда подходит, но он гарантированно избавит вас от нежелательных NullPointerException.

Оберните значение в Optional
Класс Optional – это контейнер, который может содержать, а может не содержать значение определенного типа. Идет в стандаратной библиотеке Java, так что нужно просто его использовать как есть. Посмотрите на пример кода ниже:
public class CustomerRepository {
    /* поля и конструктор */
  
    public Optional<Customer> findById(CustomerId id) {
        if (!existsById(id)) {
            return Optional.empty();
        }
        /* логика поиска Customer по id */
        return Optional.ofNullable(customer);
    }
}

public class Main {

  public static void main(String[] args) {
      CustomerRepository customerRepository = createCustomerRepository();
      Optional<Customer> customer = customerRepository.findById(new CustomerId(1));
      if (customer.isPresent()) {
          Customer value = customer.get();
          System.out.println(customer.getSalary());
      }
  }

  private static CustomerRepository createCustomerRepository() {
    /* логика создания CustomerRepository */
  }
}
Строго говоря, класс Optional является монадой, что в свою очередь относится к функциональному программированию. Но если вам хочется узнать об этом подробнее, можете ознакомиться с этой статьей.

Класс Optional использует дженерики, так что метод Optional.get() сразу возвращает нужный тип.

Пара слов о Lombok
В Java, как вы заметили, много boilerplate'а. Проблема решается с помощью Lombok. Это библиотека, которая позволяет генерировать инфраструктурный код и не писать его явно. Вот неполный список того, что может сгенерировать Lombok:
  • Геттеры
  • Сеттеры
  • With-методы для создания копии объекта с измененным полем
  • Конструкторы
  • Методы toString/equals/hashCode
По нашему мнению, начинающим Java-разработчикам лучше не использовать Lombok, потому что он скрывает детали, которые могут быть не очевидны. Но использовать его мы не запрещаем. Так что если вам интересно, можете посмотреть инструкцию установки для Maven и попробовать его в действии.