Kotlin Extension Function Resolution


#1

Have a bit of a Kotlin puzzler with extension functions:

package org.webscene.bootstrap.layout

import org.w3c.dom.Element
import org.webscene.core.html.element.childTagHandlers
import org.webscene.core.html.element.toDomElement

fun initBootstrapLayout() {
    childTagHandlers += { parent, childTag ->
        when (childTag) {
            is Container -> parent.appendChild(childTag.toDomElement())
            is Row -> parent.appendChild(childTag.toDomElement())
            is Column -> parent.appendChild(childTag.toDomElement())
        }
    }
}

fun Container.toDomElement(): Element {
    updateAttributes()
    return toDomElement()
}

fun Row.toDomElement(): Element {
    updateAttributes()
    return toDomElement()
}

fun Column.toDomElement(): Element {
    updateAttributes()
    return toDomElement()
}

Can you guess what happens when the initBootstrapLayout function is called? The reason why I am asking is that there appears to be some strange behaviour occurring in regard to how extension functions are resolved. Want to know if I have misunderstood how Kotlin extension functions are resolved, or if a obscure Kotlin bug has been discovered. Most likely to be the former rather than the latter.


#2

Your extension function will probably never be called. I’m not familiar with the framework but I think I can guess what you try to achieve. I guess Container, Row and Column all have a toDomElement function which is part of their class normally. What you try to do is to call the extension function first which then calls the original function, am I right? You can’t do it that way. Extension functions can’t override existing functions. Kotlin will always prefer a member function over an extension function.

This is documented here:

If a class has a member function, and an extension function is defined which has the same receiver type, the same name and is applicable to given arguments, the member always wins .


#3

None of the classes have a toDomElement member function defined. Here is what really happens when the initBootstrapLayout is called:

  1. A new handler is appended to childTagHandlers (a MutableList)
  2. When a childTag is a Container it is smart casted to Container and Container.toDomElement extension function is used to create a new DOM element, which is then appended to the parent
  3. When a childTag is a Row it is smart casted to Row and ParentHtmlElement.toDomElement extension function is used to create a new DOM element, which is then appended to the parent
  4. When a childTag is a Column it is smart casted to Column and ParentHtmlElement.toDomElement extension function is used to create a new DOM element, which is then appended to the parent

The strange behaviour occurring is that the extension function resolution after the smart casting is unpredictable. Container has its DOM element created correctly, yet Row and Column don’t have their DOM element created correctly.


#4

So your extension functions are supposed to be cyclic and run into a stack overflow exception? I don’t really understand what your problem is. Can you explain what exactly you want to do and what happens?

So far I can only tell that you try to call plusAssign with a lambda on some value. You don’t seem to call that lambda anywhere. Also your extension functions are all cyclic and should fail with an exception if your last statement is correct.


#5

Not running into stack overflow exceptions. Please refer to the edited post above for the actual issue. The code example was part of a third party Kotlin JS library (in a Kotlin multi-platform project), before applying a workaround to fix the issue.


#6

I agree with @Wasabi375, the example won’t work as is. The only way it can work is if toDomElement() is defined as extension of superclass (probably Element, which is confirmed by imports). Which means that child class extensions delegate to superclass extension.


#7

The workaround that I’m using is to place each extension function in a separate file and package, along with explicitly importing each extension function that will be used:

@file:Suppress("unused")

package org.webscene.bootstrap.layout

import org.webscene.bootstrap.layout.column_ext.toDomElement
import org.webscene.bootstrap.layout.container_ext.toDomElement
import org.webscene.bootstrap.layout.row_ext.toDomElement
import org.webscene.core.html.element.childTagHandlers

fun initBootstrapLayout() {
    childTagHandlers += { parent, childTag ->
        when (childTag) {
            is Container -> parent.appendChild(childTag.toDomElement())
            is Row -> parent.appendChild(childTag.toDomElement())
            is Column -> parent.appendChild(childTag.toDomElement())
        }
    }
}

Notice the additional import statements for the extension functions, which ensure the correct extension function is used in each smart cast (in the initBootstrapLayout function).


#8

Like @Wasabi375 and @darksnake , I also wonder why you’re not getting stack overflow errors (like the others pointed out, it’s probably because of another toDomElement() not shown in your code).

This is confusing because if this truly is the only toDomElement() function available within the Container.toDomElement() scope, then the function is infinitely recursive.

Here’s the code again:

fun Container.toDomElement(): Element { // <-----<----╮ 
    updateAttributes()     //                         | Infinite loop!
    return toDomElement()  // This line goes >---->---╯
}

This may not be an infinite loop if there is some other toDomElement()–Can you confirm there really is no other method that may be taking this call (possibly a method hidden in JS)?

Assuming the other method isn’t hidden in JS land, does IntelliJ detect it (Ctrl+click or Cmd+click the method)?

As @darksnake points out, maybe the culprit is this method:

EDIT:

Hmmm… I may have misunderstood.
Is this the current (non-workaround) behavior you’re describing?

when (childTag) {
    is Container -> childTag.toDomElement() // Container.toDomElement()
    is Row       -> childTag.toDomElement() // ParentHtmlElement.toDomElement()
    is Column    -> childTag.toDomElement() // ParentHtmlElement.toDomElement()
}

What’s the signature of the org.webscene.core.html.element.toDomElement method?


#9

In the org.webscene.core.html.element package there are two extension functions with the same name, ParentHtmlElement.toDomElement and HtmlElement.toDomElement. The import statement (import org.webscene.html.element.toDomElement) imports both extension functions. No extension function called toDomElement is defined for org.w3c.dom.Element.

As far as I am aware there is no hidden toDomElement function in JS. All toDomElement functions have been accounted for. Below are the definitions for the HtmlElement.toDomElement and ParentHtmlElement.toDomElement functions:

fun HtmlElement.toDomElement(): Element {
    val tmpAttributes = attributes
    val tmpId = id

    return document.createElement(tagName) {
        addClass(*classes.toTypedArray())
        tmpAttributes.forEach { (key, value) -> setAttribute(key, value) }
        id = tmpId
        if (txtContent.isNotEmpty()) appendText(txtContent)
    }
}

fun ParentHtmlElement.toDomElement(): Element {
    val tmpId = id
    val tmpAttributes = attributes
    val tmpChildren = children

    return document.createElement(tagName) {
        addClass(*classes.toTypedArray())
        tmpAttributes.forEach { (key, value) -> setAttribute(key, value) }
        id = tmpId
        if (txtContent.isNotEmpty()) appendText(txtContent)

        tmpChildren.forEach { tag ->
            val originalCount = childElementCount
            for (handler in childTagHandlers) {
                handler(this, tag)
                if (originalCount != childElementCount) break
            }
            if (originalCount == childElementCount) processHtmlInputElement(this, tag)
            if (originalCount == childElementCount) processHtmlElement(this, tag)
        }
    }
}

#10

What’s the signature of org.webscene.core.html.element.childTagHandlers and the plusAssign used in the original example?
Are any of the Container, Row, or Column classes subclasses of ParentHtmlElement or HtmlElement?


#11

Container, Row, and Column classes are subclasses of ParentHtmlElement. Here is the signature of childTagHandlers:

val childTagHandlers: MutableList<(parentElement: Element, childTag: HtmlTag) -> Unit> = mutableListOf()

There is no overloading of the plusAssign operator. Are you referring to the unaryPlus operator (it is overloaded in ParentHtmlElement and HtmlElement classes)?