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


Бэкграунд Java-программистов


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


Возьмём для примера следующий класс:


public class Point {
    public double x;
    public double y;
}

Мы имеем класс, описывающий точку на плоскости. Что с ним не так? Во-первых, так как это открытые поля, то они доступны для редактирования извне. Во-вторых, мы открываем детали реализации, что храним точку в декартовых координатах.


Поэтому, обычно, так не пишут, а инкапсулируют поля за геттерами и сеттерами:


public class Point {

    private double x;
    private double y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public double getX() {
        return x;
    }

    public void setX(double x) {
        this.x = x;
    }

    public double getY() {
        return y;
    }

    public void setY(double y) {
        this.y = y;
    }
}

Стандартная практика на Java, в IDE даже есть специальные генераторы для этого.
Мы убрали прямой доступ к полям с помощью методов get и set.


Хотя они выглядят как функции, в сущности, являются геттерами и сеттерами.


Сахар в Котлине


В Котлине у нас есть свойства и мы можем не писать простыню из методов get и set:


class Point(var x: Double, var y: Double)

Выглядит лаконично, не так ли? Обращение к свойству тоже удобно: x вместо getX().


При необходимости мы можем переопределить геттер или сеттер:


var x: Double = 0 
    set(value) {
        if (value >= 0) field = value
    }

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


Нам подвезли сахар, но старые привычки остались. Я часто замечаю, что программисты на Java продолжают писать методы get и set в классах на Котлине, когда это уже необязательно. Название самих методов говорит о том, что их можно сконвертировать в свойства. Но всегда ли нужно предпочесть свойство методу? Если у нас есть функция без параметров, то выбор не всегда очевиден.


Общепринятые соглашения


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


Ниже дан алгоритм, по которому можно определить, когда предпочесть свойство методу:


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

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


Например, если у нас есть класс User:


class User(
    val firstName: String,
    val lastName: String
)

Нужно ли полное имя делать свойством или всё же методом?


val fullName get() = "$firstName $lastName"

В данном случае создаётся всегда новый объект String, хотя это деталь реализации. Но значения всегда будут равны при сравнении через equals. На крайний случай можно закешировать fullName, пожертвовав памятью.


Но вот пункт про вычислимость вызывает больше всего вопросов.


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


Кажется, это довольно расплывчатое требование. Что значит сложно вычислимое? Если подразумеваются тяжёлые вычисления, такие как запрос в сеть или к БД, то мы должны будем вынести вызов в отдельный поток. В этом случае асинхронный вызов будет выглядеть по-другому: метод с коллбэком, реактивный поток или корутина. Но речь, скорее всего, не об этом.


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


class DocumentModel {
     val activePageIndex: Int
}

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


images.forEach { image ->
   document addImage(image, document.activePageIndex)
}

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


val pageIndex = document.activePageIndex
images.forEach { image ->
   document addImage(image, pageIndex)
}

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


Интерфейс важнее реализации


С одной стороны, приведённый выше пример показывает, насколько важно думать об интерфейсе, как он будет использоваться на клиентской стороне. С другой стороны, реализация накладывает ограничение на интерфейс. Если сложные вычисления, то нужно использовать функцию вместо свойства. Здесь мы вступаем в некоторое противоречие, что первичнее, реализация или интерфейс? Мы заранее не можем сказать об эффективности и есть соблазн впасть в крайность — всегда делать методы в интерфейсе. Особенно после Java непривычно видеть свойства в интерфейсе. При этом, метод, начинающийся со слова get или set никого не смущает.


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


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


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


Фундаментальные отличия


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


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


  • Состояние. Можно рассматривать как данные, которые описывают характеристики или черты объекта. В этом случае больше подходят свойства.
  • Поведение. То есть то, что можно сделать с объектом. За это отвечают методы.
    Они обычно изменяют состояние.

Это довольно простое правило, которое поможет при выборе свойства или метода.


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

Вместо заключения


Разберём первоначальный пример, только сделаем его интерфейсом:


interface Point {
    var x: Double
    var y: Double
}

Так что с ним не так?


Во-первых, два отдельных сеттера для координат x и y. Когда мы определяем точку в пространстве, то мы задаём их в паре, то есть атомарно. Меняя их независимо, мы создаём возможность для ошибок.


Добавим метод для задания координат и сделаем координаты x и y только для чтения:


interface Point {
    val x: Double
    val y: Double
    fun setCoordinates(x: Double, y: Double)
}

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


Расширим интерфейс:


interface Point {
    val x: Double
    val y: Double
    val radius: Double
    val angle: Double
    fun setCartesian(x: Double, y: Double)
    fun setPolar(radius: Double, angle: Double)
}

Как мы видим, хороший интерфейс спроектировать не так просто. Хотя можно было бы ограничиться data-классом:


data class Point(
    var x: Double,
    var y: Double
)