Imposter constructors in Kotlin

Today I want to talk about interesting points in Kotlin related to calls to class constructors. Or not really designers? Or Absolutely not designers? Let's figure it out.

This is an ambiguous technical article for lovers of language interests, but not without practical meaning.

Let's take a look at the following simple piece of Java code:

var controller = new MyController("something");

We can say with certainty that a new object of type is created here MyController, and the constructor of this class is called as usual with some arguments. What gives us a guarantee that this is a constructor call? Well, at least the keyword new. And, to be honest, the identifier begins with a capital letter.

This is a constructor call, right?

Now let's take a look at similar simple code in Kotlin. It's reasonable to assume that we are simply creating an object of the class MyControlleras in the Java example.

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

But can we be so sure of this? Well, of course, in most cases this guess will be correct. But not always. Next we will look at constructions (no pun intended), in the context of which the listing (1) could compile and work approximately in the way we expect, without being, strictly speaking, a constructor call.

1. Rogue Constructor: Top Level Functions

Why did we even decide that this was in the listing? (1) do we see the constructor call? Probably because classes in Java/Kotlin in any code style are usually named in the “capitalized camel-case” register. A keyword new not in Kotlin.

Kotlin allows you to declare and use top level functions (top-level functions). Again, the language syntax does not impose any restrictions on the case of their naming (this would be completely strange). But if we suddenly try to name the function in a non-standard way, for example create_my_class() or CreateMyClass() then smart IntelliJ will immediately arrogantly emphasize the name of the function and indicate that, supposedly, it’s not according to the codestyle. Of course, IDE inspections can be turned off or “suppressed” or written in Kotlin not in IntelliJ and in other ways be your own evil Pinocchio. But let's try the following trick:

// file: MyController.kt

interface MyController { /* api */ }

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

This is how it turns out that we declared a top-level function with the name MyController and return type MyController. It turned out just something like an external constructor. And even IntelliJ will suddenly stop complaining about non-compliance with the code style, because it turns out that this famous pattern for Kotlin, which is used by serious projects, for example Google in androidx.

How Java sees and can see it

To use such an API from Java, for those libraries that are not positioned as Kotlin-only, it is worth applying the annotation @JvmName. Then instead of strange

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

you can write

var myController = MyControllerKt.create("something")

adding @JvmName("create")to define a function MyController.

Why might this make sense?

This may be reasonable to apply just in case MyController is an interface, and there are several implementations of it and/or they should not be directly visible to clients. So it turns out that you can put a function instead of a constructor, but it behaves exactly like a constructor.

Of course, you can write a function MyControllerwhich returns some other type, but then the warning will not go anywhere and clients of such a function will be unpleasantly surprised.

It is likely that you may have encountered this pattern, since it is actually used, for example, in Jetpack Compose. So let's look at something more exotic.

2. Rogue constructor: Companion.invoke()

Let's look at another option on how to prepare a constructor “without a constructor”:

interface MyController {
  // api

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

In this situation, the line from the listing (1) again it will be valid. To understand why this works, let's try not using the sugar operator syntax invoke and explicitly address the companion. Then our “constructor” immediately expands into the following:

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

How can this be useful?

This thing can be used in cases where we want to completely control the creation of class instances, for example, use a global pool of objects or a cache. But at the same time, we don’t want to pollute the creation site with unnecessary details about it. Also, if we suddenly decide to abandon this delegated creation completely, then all call places can be left unchanged – nothing will change in the text, and the compiler will resolve a regular constructor for a class or a top-level function for an interface instead Companion.invoke().

Global cache example
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)
    }
  }
}

What if you remove the operator from the companion

If you go a little further along the strange path of reasoning that we have chosen today, you can reach the following:

interface MyController {
  /* api */
  companion object
}

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

Hmm, the shape is slightly different, but we don’t gain anything useful in this form. Also import functions invoke will be added at each call location. However, the important fact here is that the operator invoke does not have to be a member of the companion object. Which leads to another interesting option…

3. Rogue constructor: .invoke()

Before moving on to another perverted way to pretend to be a designer, I’ll just say that it’s important to add context to listing (1). Let's add it like this:

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

Now let’s look at what can be meant by 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 */ }
}

In this situation, in context Context we will have a resolved operator invoke with two receivers at once – with MyController.Companion and c context: Context. Let's get rid of the syntactic sugar in listing (1') to understand what's really going on:

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

How can this be useful?

The point of this trick is that we can delegate the creation of objects within some local (as opposed to the global from previous cases) context. And we can do this by simply injecting the context as a receiver, while keeping the object creation itself looking like a normal constructor call.

Possible context and companion implementation, including the ability to pass parameters other than StringI do not present it here, for fear of overcomplicating the article with technical details dubious highly specialized approach. However, if anyone is interested, a real example of the use of such a thing can be found see in Yatagan code.

Why is it so difficult or nuances with receivers?

It would be much easier if we left the statementinvoke in the companion itself, as in case 2, while adding a receiver to it Contextand not vice versa, as we did – to carry away invoke V Context and give him the receiver Factory<T>, which is implemented by the partner. WTF?!

interface Context { /*api*/ }

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

But, unfortunately, then Kotlin would not allow us to call it the way we want. The point is that one receiver must be explicit, and the second must be implicit, and they cannot be swapped.

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

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

It’s a shame that the necessary feature to solve this already exists in Kotlin, and for a long time, but only in the form of a prototype – Context receivers. With it we could write code:

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

And then listing (1') would be valid and pony world cookies. But the Context receiver is stuck in a prototype state, and it is unclear when it will be brought to fruition.

It is worth noting that this pattern can be replaced with a simple top-level function fun Context.MyController(name: String): MyController. That is, in case 1, but with a receiver. But then this thing will not be able to access the private constructor of the class, and will only make sense for the public API of the library.

Conclusion (TL;DR)

Well, we tried to figure out how to pretend to be a constructor in Kotlin and how to get some benefit from it. We went through the cases from less stubborn to moderately stubborn and very stubborn:

  • Top-level functions with the same name as the class/interface. Useful in library APIs for instantiating public interfaces when you don't want to expose implementation names/details. Used in practice.

  • Operator Companion.invoke(). Can be useful for managing object creation (pooling, caching, …) in a static context.

  • Operator receiver(Context) Companion.invoke() (in the syntax of context receivers, without them the matter becomes more complicated). Can be useful for managing object creation (pooling, caching, …) in local Context.

The next article should be called “How to stop pretending to be a constructor in Kotlin and start living,” but I’m unlikely to write it.

Write in the comments what you think about the methods described, whether you used something from here or just stood next to it (I won’t judge). Or suddenly someone will make something of themselves. Thank you for your attention!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *