Обзор всех модификаторов в Java

Модификаторы это ключевые слова в Java, которые "изменяют и регулируют" работу классов, методов и переменных.

· 13 минуты на чтение

Все члены класса в языке Java имеют модификаторы. Модификаторы — это ключевые слова, которые изменяют и регулируют поведение классов, методов и переменных.

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

Спонсор поста

Прочие модификаторы

Начнём разбор с модификаторов, которые не входят в отдельные группы — модификаторы-одиночки.

Модификатор final

С английского “final” переводится как “последний” или “окончательный”. Этот модификатор защищает переменные, методы и классы от изменений.

Переменная final

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

Для final переменных на уровне класса значение устанавливается сразу или через конструктор.

public class Foo {

    private final int a = 10;
    private final Bar bar;

    public Foo(Bar bar) {
        this.bar = bar;
    }

}

class Bar {
    int b = 10;
}

В примере выше переменная a инициализируется сразу, а переменная bar — через конструктор. Изменить их значения нельзя (строки 13 и 14). Однако внутреннее состояние объекта, на который ссылается final переменная, может быть изменено (строка 16). Таким образом, состояние полей объекта, на который ссылается final переменная, может изменяться.

Аргументы метода и локальные переменные также могут быть объявлены как final, что запрещает их изменение:

public void method(final int a) {
    final long b = 10;

    a = 10; // error
    b = 20; // error
}

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

public void method() {
    final long b;
    System.out.println(b); // error
}

Но на локальные переменные метода это правило не распространяется. Вы можете объявить локальную переменную как final, но инициализировать её позже. Такое поведение позволяет инициализировать final переменную по условию:

public void method(int a) {
    final long b;
    if (a > 10) {
        b = 20;
    } else {
        b = 30;
    }
    b = 50; // error
}

Метод final

Метод, объявленный с модификатором final, нельзя переопределить в подклассе:

class Foo {

    final void method() {
        System.out.println("test");
    }

}

class Bar extends Foo {

    final void method() { // error
        System.out.println("test");
    }

}

Это полезно, когда нужно разрешить наследование класса, но запретить переопределение конкретного метода.

Класс final

Если класс объявлен как final, это означает, что его нельзя наследовать:

final class Foo {
    
}

class Bar extends Foo { // error

}

Модификатор static

Обычный подход в Java заключается в создании класса, затем создании его экземпляра (объекта), и вызове методов этого класса через объект. Однако с использованием модификатора static можно объявлять методы и переменные, которые не требуют создания объекта класса. Это означает, что методы и переменные с модификатором static вызываются напрямую через класс, а не через объект.

Модификатор static не может быть применён к конструкторам или обычным классам, но его можно использовать для вложенных классов.

package p1;

class Foo {

    static String field = "Test";

    static String getString() {
        return "Test two";
    }

}

class Main {
    public static void main(String[] args) {
        System.out.println(Foo.field);
        System.out.println(Foo.getString());
    }
}

В этом примере мы видим, что статическое поле field и статический метод getString вызываются напрямую через класс Foo, без создания его экземпляра.

Хотя технически можно вызывать static методы и переменные через объект, делать это не рекомендуется. Пример:

public static void main(String[] args) {
	final Foo foo = new Foo();
	System.out.println(foo.field);
	foo.getString();
}

Так делать не стоит!

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

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

Интересная особенность статических методов заключается в том, что статический метод нельзя переопределить. Это означает, что при работе со статическими методами механизм override невозможен.

Если вы объявите одинаковый статический метод в классе-наследнике и в родительском классе, вы не переопределите метод, а “скроете” его. При обращении к такому методу его вызов будет определяться типом ссылки, а не фактическим типом объекта. Рассмотрим пример:

public class Foo {
    public static void main(String[] args) {
        final ClassOne classOne = new ClassTwo();
        System.out.println(classOne.method());
    }
}

class ClassOne {
    public static String method() {
        return "ClassOne";
    }
}

class ClassTwo extends ClassOne {
    public static String method() {
        return "ClassTwo";
    }
}

В этом примере у нас есть два класса: ClassOne и его наследник ClassTwo. Оба класса имеют одинаковый статический метод method. Мы создаём объект класса ClassTwo, но сохраняем его в переменной типа ClassOne:

final ClassOne classOne = new ClassTwo();

Затем вызываем статический метод:

System.out.println(classOne.method());

Результатом будет вывод строки "ClassOne". Это связано с тем, что для статических методов используется механизм раннего связывания. Вызов статического метода происходит на основании типа переменной (classOne имеет тип ClassOne), а не фактического объекта, который хранится в переменной.

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

Важно соблюдать баланс при использовании статических методов. Бизнес-логику никогда не стоит размещать в статических методах, так как она обычно зависит от состояния объектов. В то же время утилитарные функции, такие как преобразование строк или удаление символов, вполне могут быть статическими. Однако не злоупотребляйте этим: если для работы статического метода требуется много аргументов, стоит задать себе вопрос: “Должен ли этот метод быть статическим?”.

Константы

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

public class Constants {
    public static final String APP_NAME = "MyApp";
    public static final int MAX_USERS = 100;
}

В этом примере APP_NAME и MAX_USERS — это константы, доступные для использования в любой части программы.

Утилитарные классы

Статические методы, связанные с определённой областью задач, часто группируются в отдельные классы, называемые утилитарными классами (Utility classes). Утилитарные классы содержат статические методы, которые предоставляют вспомогательные функции для различных задач.

Пример утилитарного класса — FileUtils, встречающийся во многих библиотеках, таких как Apache Commons IO. Его задача — работа с файлами. Внутри такого класса можно найти методы вроде boolean exists(File file) или void writeToFile(List<String> data).

Хороший пример такого подхода — FileUtils, который есть во множестве библиотек, Apache Commons IO в их числе. Область применения такого класса — это работа с файлами. Если заглянуть внутрь, мы обнаружим методы, наподобие boolean exists(File file), void writeToFile(List<String> data) и так далее.

В идеале, утилитарный класс также должен быть объявлен как final, чтобы предотвратить его наследование. Наследование таких классов обычно не требуется, поскольку они содержат только статические методы.

Фабричный метод

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

Примеры таких фабричных методов в Java: String.valueOf(15), Integer.valueOf("14"). Эти методы принимают различные аргументы и возвращают объекты соответствующих типов, применяя внутреннюю логику преобразования.

Статический блок кода

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

Синтаксис статического блока выглядит следующим образом:

public class UserService {
    
    static {
        // your code
    }
    
}
📑
Если в классе несколько статических блоков, они будут выполняться в порядке их объявления.

Статические блоки часто используются для инициализации статических полей, как показано в следующем примере:

public class UserService {

    private static final Map<Long, String> USERS = new HashMap<>();

    static {
        USERS.put(0L, "username1");
        USERS.put(1L, "username2");
        USERS.put(2L, "username3");
    }

}

Здесь мы инициализируем статическое поле USERS с помощью статического блока, который заполняет карту несколькими значениями.

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

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

В Java можно объявить класс внутри другого класса. Такие классы называются вложенными (nested). Вложенные классы делятся на статические и нестатические.

Статические вложенные классы называются также inner-классами. Пример синтаксиса:

class OuterClass {
  static NestedClass {

  }

  class InnerClass {

  }
}

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

Нестатические вложенные классы (они также называются просто внутренними классами) принадлежат экземпляру внешнего класса и для их создания требуется объект внешнего класса.

Различие между ними заключается в том, что статические вложенные классы существуют независимо от экземпляров внешнего класса, тогда как внутренние классы связаны с экземпляром и имеют доступ к его полям и методам.

Модификатор abstract

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

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

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

abstract class Foo {

}

class Bar extends Foo {

}

class Main {
    public static void main(String[] args) {
        final Foo foo = new Foo(); // error
        final Foo bar = new Bar(); // success
    }
}

В этом примере попытка создания объекта абстрактного класса Foo приведёт к ошибке компиляции. Однако создание объекта класса Bar, который наследует Foo, будет успешным.

Класс не может быть одновременно abstract и final. Модификатор final запрещает наследование, тогда как абстрактный класс предназначен именно для наследования.

Абстрактный класс может содержать как обычные, так и статические методы.

Абстрактный метод

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

abstract class Foo {

    abstract void method();
    
}

class Bar extends Foo {

    @Override
    void method() {
        
    }
    
}

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

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

Модификатор sealed

Модификатор sealed был добавлен в Java 15 и позволяет ограничивать наследование, обеспечивая контроль над иерархией классов.

С его помощью можно указать, какие именно классы могут наследоваться от данного класса, что усиливает безопасность и инкапсуляцию. Класс, объявленный как sealed, должен явно указывать разрешённые классы-наследники с помощью ключевого слова permits.

Пример использования sealed:

public sealed class Shape permits Circle, Square {
    // Общая логика для всех фигур
}

public final class Circle extends Shape {
    // Логика для круга
}

public final class Square extends Shape {
    // Логика для квадрата
}

В этом примере класс Shape ограничивает возможные наследники только классами Circle и Square. Каждый класс-наследник sealed-класса обязан быть объявлен с модификатором final, sealed или non-sealed. Попытка наследования от Shape другим классом или отсутствие одного из обязательных модификаторов вызовет ошибку компиляции.

  • final — окончательно запрещает наследование для этого класса.
  • sealed — позволяет классу иметь ограниченное количество наследников.
  • non-sealed — снимает ограничения и разрешает неограниченное наследование.

Пример использования non-sealed:

public non-sealed class Square extends Shape {
    // Square теперь может быть расширен другими классами
}

Модификатор synchronized

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

synchronized void showDetails(){
	// your code
}

В Java модификатор synchronized часто используется для синхронизации доступа к общим ресурсам в многопоточной среде, чтобы предотвратить состояние гонки (race condition).

Модификатор volatile

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

volatile int sharedVariable;

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

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

Модификатор transient

Модификатор transient используется для переменных экземпляра, которые не должны участвовать в процессе сериализации объекта. Если переменная помечена как transient, Java Virtual Machine (JVM) пропустит её при сериализации, и её значение не будет записано в поток данных.

transient int limit = 55;   // не будет сериализована
int b; // будет сериализована

Это полезно, когда нужно исключить переменные, которые не имеют смысла сохранять (например, кэшированные данные или временные значения). Однако в современной Java сериализация часто осуществляется с помощью других библиотек, таких как Jackson или Protocol Buffers, поэтому использование transient стало менее распространённым.

Спонсор поста 3

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

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

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

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

Приватный конструктор

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

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

Зачем нужен такой конструктор?

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

  • Статические фабричные методы
  • Создание утилитарных классов, также упоминаемые раньше. Обычно вы не хотите, чтобы кто-то создавал объекты таких классов, так как это не имеет никакого смысла.

Класс private

Важно понимать, что модификатор доступа private нельзя использовать для самого класса, если этот класс является верхнеуровневым. Например, для класса Foo модификатор private поставить не получится:

public class Foo {

    private String field;
    
    private class Bar {
        
    }

}

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

Приватный метод

Методы с модификатором private недоступны для вызова из других классов. Это ограничивает доступ к таким методам только внутри самого класса.

class Foo {

    private void privateMethod() {

    }

    public void method() {

    }

}

class Bar {

    void method(Foo foo) {
        foo.method(); // success
        foo.privateMethod(); // error
    }

}

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

class Foo {

    private void privateMethod() {

    }

}

class ChildFoo {

    void method() {
        this.privateMethod(); // error
    }

}

Приватные методы недоступны для классов-наследников, поэтому их нельзя переопределить. Это делает бессмысленным использование комбинации private final, поскольку приватный метод не виден в наследниках и уже не может быть переопределён.

Переменная private

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

Модификатор доступа “по умолчанию” (default)

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

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

Рассмотрим пример с классами Foo и Bar, находящимися в одном пакете p1, и классом DifferentPackage, который находится в другом пакете p2.

package p1;

public class Foo {

    int defaultField;

    Foo(int i, int i2) {

    }

    void defaultMethod() {

    }

}
package p1;

public class Bar {

    public void testMethod(Foo foo) {
        foo.defaultField = 2; // success
        foo.defaultMethod(); // success
        foo = new Foo(1); // success
    }

}

Здесь никаких ошибок не будет, так как оба класса (Foo и Bar) находятся в одном пакете p1, что позволяет классу Bar свободно использовать все поля, методы и конструкторы класса Foo с доступом по умолчанию.

Однако если мы попробуем сделать то же самое в классе DifferentPackage, который находится в другом пакете p2, мы столкнемся с ошибками:

package p2;

public class DifferentPackage {

    public void testMethod(Foo foo) {
        foo.defaultField = 2; // error
        foo.defaultMethod(); // error
        foo = new Foo(1); // error
    }

}

В этом примере класс DifferentPackage из пакета p2 не имеет доступа к полям, методам и конструкторам с доступом по умолчанию в классе Foo из пакета p1.

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

Модификатор доступа protected сочетает в себе свойства модификаторов private и default-package. Он позволяет членам класса быть доступными как внутри того же пакета, так и в классах-наследниках, даже если они находятся в других пакетах.

Рассмотрим пример, где у нас есть класс Foo в пакете p1 и два других класса: ChildFoo, который является наследником Foo и находится в пакете p2, и DifferentPackage (также в пакете p2), который не является наследником.

package p1;

public class Foo {

    protected int defaultField;

    protected Foo(int i) {

    }

    protected void defaultMethod() {

    }

}

Класс Foo имеет protected поле, конструктор и метод. Эти элементы будут доступны в пределах пакета p1, а также в любом классе-наследнике, даже если он находится в другом пакете.

Код класса ChildFoo в пакете p2, который наследует класс Foo:

package p2;

import p1.Foo;

public class ChildFoo extends Foo {

    protected ChildFoo(int i) {
        super(i);
    }

    void method() {
        System.out.println(defaultField);
        defaultMethod();
    }

}

Класс ChildFoo, будучи наследником Foo, может свободно обращаться к полям, методам и конструкторам с модификатором protected. Обратите внимание, что необходимо реализовать конструктор в классе ChildFoo, который вызывает конструктор родителя с помощью super(i).

Теперь посмотрим, как ведет себя класс DifferentPackage, который находится в том же пакете p2, но не является наследником класса Foo:

package p2;

import p1.Foo;

public class DifferentPackage {

    public void testMethod(Foo foo) {
        foo.defaultField = 2;      // error: доступ к полю запрещён
        foo.defaultMethod();       // error: доступ к методу запрещён
        foo = new Foo(1);          // error: доступ к конструктору запрещён
    }

}

В данном случае класс DifferentPackage не является наследником Foo, поэтому он не имеет доступа к полям, методам и конструкторам с модификатором protected, даже если он импортировал класс Foo.

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

Модификатор public предоставляет максимальный уровень доступа. Члены класса (переменные, методы, конструкторы), объявленные с модификатором public, могут быть доступны из любого другого класса, независимо от пакета. Это означает, что к публичным членам можно обратиться как внутри одного пакета, так и из других пакетов.

  • Если это переменная, любой класс из любого пакета может её прочитать и изменить.
  • Если это метод, любой класс может его вызвать.
  • Если это конструктор, любой класс может его использовать для создания объекта.
package p1;

public class Foo {

    public int publicField;  // Публичное поле

    public Foo(int i) {      // Публичный конструктор
        this.publicField = i;
    }

    public void publicMethod() {  // Публичный метод
        System.out.println("Public method called");
    }
}

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

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

package p2;

import p1.Foo;

public class DifferentPackage {

    public void testMethod() {
        Foo foo = new Foo(10);      // Доступ к публичному конструктору
        System.out.println(foo.publicField);  // Доступ к публичному полю
        foo.publicMethod();         // Доступ к публичному методу
    }

}

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

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

Шпаргалка для модификаторов доступа переменной

Визуально модификаторы доступа переменной класса можно представить таким образом:

Резюмирую

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

Важно помнить, что не ко всем членам класса можно применять определённые модификаторы. Кроме того, некоторые модификаторы несовместимы друг с другом. Например, класс не может быть одновременно abstract и final, а методы не могут быть abstract и static одновременно.

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

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇