Сегодня я хочу поговорить про интересные моменты в 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 { 
  receiver(Context)
  operator fun invoke(name: String) = ... // Что-то тут делаем.
}

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

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

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

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

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

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

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

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

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

Комментарии (11)


  1. tipapro
    06.04.2024 11:12
    +6

    Многое из этого приходит только с опытом написания api для других разработчиков и чтения примеров реализации в либах. Довольно круто, что собрали всё это в одной статье. Никогда не нравилось, когда используют java-like factory-методы там, где можно обойтись красивым синтаксическим сахором, сделав апи прекраснее


  1. igornem
    06.04.2024 11:12

    Говно этот ваш котлин


    1. TIEugene
      06.04.2024 11:12
      +5

      Любой ЯП говно, если правильно прицелиться в свою ногу


    1. KvanTTT
      06.04.2024 11:12

      Почему?


      1. igornem
        06.04.2024 11:12

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


  1. kacetal
    06.04.2024 11:12

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


    1. iliazeus
      06.04.2024 11:12
      +3

      Так а разве здесь есть какие-то проблемы? Статья, наоборот, описывает, что за счёт этого можно удобно реализовать многие вещи.


  1. igornem
    06.04.2024 11:12

    Ну, вот только что показали неоднозначность из-за того что убрали слово new, а ещё убрали checked exceptions и подозреваю ещё кучу чего полезного сделали через жопу


    1. m0rtis
      06.04.2024 11:12
      +1

      Да-да, только вот все последние нововведения в Джаве очень похожи на погоню за Котлином.

      А вообще, вам бы как-то по-культурнее, что ли, мысли выражать. А то в каждом комменте то "говно", то "жопа".


      1. igornem
        06.04.2024 11:12

        Я согласен, что есть определенно полезные вещи типа отмены необходимости писать сеттеры/геттеры и, в частности, именно эта фича решалась использованием Lombok еще до появления Котлин.

        По поводу погони: много фич и из Котлина и из другого дерьма(типа Скалы, Groovy и т.п.) продиктованы маркетинговыми усилиями, поясню - на одной из конференций СТО JetBrain сообщил, что при создании Котлин руководствовались принципом сделать язык проще, и, в чем-то он наверное прав, в частности, общепринятая причина отсутствия checked exceptions это - "пользователи не умеют их правильно использовать" (но я не согласен, что обрезание функциональности улучшает ЯП),в результате из Java комьюнити оттянулась часть в Колин и, чтобы их удержать/вернуть приходится вводить те же фичи, чего доброго и исключения вырежут.

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


  1. Spyman
    06.04.2024 11:12
    +1

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

    С другой стороны - один и тот-же код может вести себя по разному в разном окружении! Как можно в чём то вообще быть увереным!
    Напоминает ситуацию с пропертями (val/var) - когда это может быть локальная переменная, а может быть вычисляемое значение - и вот уже на первый взгляд одинаковые
    item.property.one + item.property.two + item.property.three
    и
    item.property.let { one + two + three }
    на деле совершенно не одинаковые.