Конструкторы класса в Kotlin

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

a.number = 10
a.step = 2

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

val a = NumInc(10, 2)

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

class NumInc {
    var number = 0
    var step = 1
 
    fun inc() {
        number += step
    }
    fun dec() {
        number -= step
    }
}

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

Чтобы выражение NumInc(10, 2) работало, определить класс в Kotlin можно так:

class NumInc(n: Int, gap: Int) {
    var number = n
    var step = gap
 
    fun inc() {
        number += step
    }
    fun dec() {
        number -= step
    }
}

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

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

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

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

Мы можем привести пример выше в более привычную для многих объектно-ориентированных языков форму:

class NumInc {
    var number: Int
    var step: Int
 
    constructor(num: Int, gap: Int) {
        number = num
        step = gap
    }
 
    fun inc() { number += step }
    fun dec() { number -= step }
}

Здесь конструктор – это особая функция, которая определяется не через fun и имя функции, а через ключевое слово constructor. Функция constructor() будет неявно вызываться при создании объекта.

В Kotlin конструкторы, определенные в теле класса, называются вторичными. И если у класса нет первичного, то IntelliJ IDEA предложит преобразовать вторичный в первичный:

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

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

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

class NumInc() {
...

Если при этом в классе есть метод constructor(), значит, что у класса два конструктора – первичный и вторичный. В этом случае вторичный нельзя конвертировать в первичный, иначе вы "убьете" уже имеющийся первичный. То, что первичный не имеет параметров, вопрос другой. Главное, что первичный конструктор определен.

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

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

val a = NumInc(10, 2)
val b = NumInc()

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

class NumInc(num: Int = 0, gap: Int = 1) {
    var number: Int = num
    var step: Int = gap
 
    fun inc() { number += step }
    fun dec() { number -= step }
}

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

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

class NumInc() {
    var number: Int = 0
    var step: Int = 1
 
    constructor(num: Int, gap: Int): this() {
        number = num
        step = gap
    }
 
    fun inc() { number += step }
    fun dec() { number -= step }
}

Первичный конструктор будет вызываться, когда объект создается без передачи значений свойств. Вторичный – когда значения передаются.

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

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

Откатим назад и вернемся к варианту с одним первичным конструктором.

class NumInc(n: Int, gap: Int) {
    var number = n
    var step = gap
 
    fun inc() {
        number += step
    }
    fun dec() {
        number -= step
    }
}

Котлин позволяет определять свойства сразу в заголовке класса.

class NumInc(var number: Int, var step: Int) {
    fun inc() {
        number += step
    }
    fun dec() {
        number -= step
    }
}

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

В заголовке мы заменили имена n и gap на number и step, но это не главное. Если бы раньше класс выглядел так

class NumInc(number: Int, step: Int) {
    var number = number 
    var step = step
 
    ...
}

то number и step в заголовке были бы другими переменными, а не теми, что поля свойств. Просто одноименными. Это вполне рабочий вариант, однако чтобы различать что есть что, бывает лучше давать различные имена.

Главное, что изменилось, это появились модификаторы var (или val). Они то и сообщают компилятору, что мы не просто определяем первичный конструктор, также одновременно объявляем свойства объекта (не обязательно все).

Рассмотрим вариант с первичным и двумя вторичными конструкторами:

fun main() {
    val a = NumInc(10, 2)
    val b = NumInc(5)
    val c = NumInc()
    a.inc()
    b.dec()
    c.inc()
    println("a: ${a.number}")
    println("b: ${b.number}")
    println("c: ${c.number}")
}
class NumInc(var number: Int, var step: Int) {
 
    constructor(num: Int) : this(num, 1)
 
    constructor() : this(0, 1)
 
    fun inc() {
        number += step
    }
    fun dec() {
        number -= step
    }
}

Здесь в обоих вторичных конструкторах через this() мы вызываем первичный конструктор, так как только он принимает два аргумента. Однако с таким же успехом можно было из одного вторичного конструктора вызвать другой вторичный, который уже в свою очередь вызывает первичный.

class NumInc(var number: Int, var step: Int) {
 
    constructor(num: Int): this(num, 1)
 
    constructor(): this(0)
...

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

Давайте снова вернемся к присвоению свойствам значений в теле класса:

class NumInc(num: Int, gap: Int) {
    var number = num
    var step = gap
 
    constructor(num: Int): this(num, 1)
    constructor(): this(0, 1)
 
    fun inc() { number += step }
    fun dec() { number -= step }
}

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

class NumInc {
    var number: Int
    var step: Int
 
    constructor() { number = 0; step = 1}
    constructor(num: Int) {
        number = num; step = 0}
    constructor(num: Int, gap: Int) {
        number = num; step = gap}
 
    fun inc() { number += step }
    fun dec() { number -= step }
}

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

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

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

class NumInc {
    var number = 0
    var step = 1
 
    constructor()
    constructor(num: Int) { number = num}
    constructor(num: Int, gap: Int) {
        number = num; step = gap}
 
    fun inc() { number += step }
    fun dec() { number -= step }
}

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

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

class NumInc(var number: Int = 0,
             var step: Int = 1) {
 
    fun inc() { number += step }
    fun dec() { number -= step }
 
}

Практическая работа:

Можно ли от класса, рассматриваемого в данном уроке, создать объект, передав в конструктор значение для свойства step, но не указав значение для number?

PDF-версия курса с ответами к практическим работам


Введение в объектно-ориентированное программирование на Kotlin




Все разделы сайта