Как стать автором
Обновить

Конструкторы-самозванцы в Kotlin

Время на прочтение7 мин
Количество просмотров10K

Сегодня я хочу поговорить про интересные моменты в Kotlin, связанные с вызовами конструкторов классов. Или не совсем конструкторов? Или же совсем не конструкторов? Давайте разбираться.

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

Взглянем на следующий простой фрагмент кода на Java:

var controller = new MyController("something");

Мы можем с точностью сказать, что тут создаётся новый объект типа MyController, при этом как обычно вызывается конструктор этого класса с некоторыми аргументами. Что даёт нам гарантию того, что перед нами именно вызов конструктора? Ну, как минимум, ключевое слово new. И, что уж греха таить, идентификатор с большой буквы начинается.

Это же вызов конструктора, правда?

А теперь взглянем на аналогичный незамысловатый код на Kotlin. Разумно предположить, что мы просто создаём объект класса MyController, как и в примере на Java.

// Далее по тексту - Листинг (1)
val controller = MyController("something")

Но можем ли мы быть так в этом уверены? Ну, конечно, в большинстве случаев такая догадка окажется верной. Но не всегда. Далее мы рассмотрим конструкции (no pun intended), в контексте которых листинг (1) мог бы компилироваться и работать примерно так, как мы ожидаем, не являясь при этом, строго говоря, вызовом конструктора.

1. Конструктор-мошенник: функции верхнего уровня

А почему мы вообще решили, что это в листинге (1) мы видим именно вызов конструктора? Наверное потому, что классы в Java/Kotlin в любом код-стайле принято называть в "capitalized camel-case" регистре. А ключевого слова new в Kotlin нет.

В Kotlin разрешено объявлять и использовать функции верхнего уровня (top-level functions). Опять же, никакого ограничения на регистр их именования синтаксис языка не накладывает (это было бы совсем уж странно). Но если мы вдруг попробуем как-то нестандартно назвать функцию, например create_my_class() или CreateMyClass() то сообразительная IntelliJ сразу надменно подчеркнёт имя функции и укажет, что, мол, не по кодстайлу. Кончено, инспекции IDE можно выключить или "подавить" или писать на Kotlin не в IntelliJ и прочим образом быть сами себе злыми Буратино. Но давайте всё же попробуем следующий трюк:

// file: MyController.kt

interface MyController { /* api */ }

fun MyController(name: String): MyController {
  // Например так. Но вообще тут может быть всё что угодно.
  return object : MyController { /* implementation */ }
}

Вот так и получится, что мы объявили top-level функцию с именем MyController и возвращаемым типом MyController. Получилось как раз что-то вроде внешнего конструктора. И даже IntelliJ неожиданно перестанет ругаться на несоответствие код-стайлу, потому что, оказывается, это известный паттерн для Kotlin, который используют серьезные проекты, например Google в androidx.

Как это видит и может видеть Java

Чтобы пользоваться таким API из Java, для тех библиотек, которые не позиционируются как Kotlin-only, стоит применить аннотацию @JvmName. Тогда вместо странного

var myController = MyControllerKt.MyController("something");

можно написать

var myController = MyControllerKt.create("something")

добавив @JvmName("create")на определение функции MyController.

Почему это может иметь смысл?

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

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

Вполне вероятно, вы могли сталкиваться с этим паттерном, так как он действительно применяется, например в Jetpack Compose. Так что давайте рассмотрим что-нибудь более экзотическое.

2. Конструктор-мошенник: Companion.invoke()

Посмотрим на ещё один вариант того, как приготовить конструктор "без конструктора":

interface MyController {
  // api

  companion object Factory {
    operator fun invoke(name: String): MyController {
      return object : MyController { /* implementation */ }
    }
  }
}

При таком раскладе строка из листинга (1) опять же будет валидна. Чтобы разобраться, почему это работает, давайте попробуем не пользоваться сахарным синтаксисом оператора invoke и явно обратиться к компаньону. Тогда наш "конструктор" сразу раскрывается в следующее:

val myController = MyController.Factory.invoke("something")
// <=> просто MyController("something")

Чем это может быть полезно?

Такую штуку можно применять в случае, когда мы хотим полностью контролировать создание экземпляров класса, например, применять глобальный пул объектов или кэш. Но при этом не хотим загрязнять места создания лишними деталями про это. Так же, если мы вдруг решим отказаться от такого делегированного создания совсем, то все места вызова можно будет оставить без изменений - в тексте ничего не поменяется, а компилятор станет резолвить обычный конструктор для класса или top-level функцию для интерфейса вместо Companion.invoke().

Пример с глобальным кэшем
class MyController 
// Приватный конструктор, чтобы никто извне не мог его вызвать в обход Cache
private constructor(val name: String) {
  companion object Cache {
    // Наивный глобальный кэш объектов. Чистка, WeakReference, прочее - 
    // все допустимо, но остаётся упражнением увлечённым читателям.
    private val cache = hashMapOf<String, MyConstroller>()

    operator fun invoke(name: String) = cache.getOrPut(name) {
      MyController(name)
    }
  }
}

А что если вынести оператор из компаньона

Если пройти чуть дальше по странной дорожке рассуждений, которую мы с вами сегодня выбрали, то можно дойти до следующего:

interface MyController {
  /* api */
  companion object
}

// Оператор вызова, теперь уже как расширение и на верхнем уровне.
operator fun MyController.Companion.invoke(name: String): MyController {
  /* something */ 
}

Хм, форма слегка другая, но ничего полезного в таком виде мы не выигрываем. Ещё и импорт функции invoke добавится в каждом месте вызова. Тем не менее тут важен факт, что оператор invoke не обязательно должен быть членом объекта-компаньона. Из чего следует ещё один занимательный вариант...

3. Конструктор-мошенник: <Context>.invoke()

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

// Листинг (1')
with(context) {  // Или любая другая конструкция, вводящая context: Context как ресивер
  val myController = MyController("something")
}

А теперь рассмотрим, что же такое тут может пониматься под MyController("something"):

interface Context {
  // Наш конструктор-самозванец. Обратите внимание, что это extension-функция-член,
  // и ей будут нужны два ресивера.
  operator fun <T> Factory<T>.invoke(name: String): T

  interface Factory<T> { /* какой-нибудь API для реального создания объектов T */ }
}

class MyController private constructor(val name: String) {
  companion object : Context.Factory<MyController> { /* implementation */ }
}

При таком раскладе в контексте Context у нас будет резолвится оператор invoke сразу с двумя ресиверами - с MyController.Companion и c context: Context. Давайте избавимся от синтаксического сахара в листинге (1') чтобы понять, что на самом деле происходит:

val context: Context = ... // где-то как-то создаётся реализация контекста
with(context) {
  // invoke вызывается с двумя ресиверами: явным (Companion) и неявным (context).
  MyController.Companion.invoke("something")
}
// или еще более явно (но синтаксически некорректно):
// context.invoke(MyController.Companion, "something")

Чем это может быть полезно?

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

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

А почему так сложно или нюансы с ресиверами

Было бы намного проще, если оставить операторinvoke в самом компаньоне, как в случае 2, при этом добавив ему ресивер Context, а не наоборот, как сделали мы - уносить invoke в Context и давать ему ресивер Factory<T>, который реализуется компаньоном. WTF?!

interface Context { /*api*/ }

class MyController private constructor(val name: String) {
  companion object { 
    // Казалось бы - проще. И контекст получаем, и параметры любые.
    operator fun Context.invoke(name: String) = ... // Что-то тут делаем.
  }
}

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

with(context) {
  MyController.Companion.invoke("something") // Ошибка!
}

with(MyController.Companion) {
  context.invoke("something") // ОК, но не то, что нам нужно.
}

Обидно, что нужная фича для решения этого в Kotlin уже есть, причем давно, но только в виде прототипа - Context receivers. С ней мы бы могли написать код:

// ~~~
companion object { 
  context(Context)
  operator fun invoke(name: String) = ... // Что-то тут делаем.
}

И тогда листинг (1') был бы валиден и мир пони печенье. Но, Context receivers застрял в прототипном состоянии, и непонятно, когда его доведут до ума.

Стоит отметить, что такой паттерн можно заменить на простую top-level функцию fun Context.MyController(name: String): MyController. То есть на случай 1, но с ресивером. Но тогда эта штука не сможет получить доступ к приватному конструктору класса, и будет иметь смысл только для публичного API библиотеки.

Заключение (TL;DR)

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

  • Функции верхнего уровня с именем, совпадающим с именем класса/интерфейса. Полезно в API библиотек для создания экземпляров публичных интерфейсов, когда не хочешь открывать имена/детали реализаций. Используется на практике.

  • Оператор Companion.invoke(). Может быть полезно для управления созданием объектов (пул, кэширование, ...) в статическом контексте.

  • Оператор context(Context) Companion.invoke() (в синтаксисе context receivers, без них дело усложняется). Может быть полезно для управления созданием объектов (пул, кэширование, ...) в локальном Context.

Следующая статья должна будет называться "Как перестать прикидываться конструктором в Kotlin и начать жить", но её я вряд ли напишу.

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

Теги:
Хабы:
Всего голосов 36: ↑35 и ↓1+52
Комментарии11

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань