Why are Function parameters not `crossinline` by default?


#1

When a developer designs an inline function, it might not be so likely that he expects (or even remembers that it is possible) callers to have beyond-inline-scope control flow inside the inline function. Breaking control flow from inside the inline function is likely to cause instability. Although inline functions should only be used for very small functions that only access the public API of any other types, it might not be so common that developers want to use it.

A simple example where crossinline might be intended but not so obvious:

class IndentedWriter(val writer: Writer): Writer by writer{
    var indentLevel = 0

    inline fun writeIndented(fn: () -> Unit){
        indentLevel++
        fn() // non-local return will stop the indentLevel-- from executing
        indentLevel--
    }
}

One may argue that indentLevel should have been private, but under special cases (e.g. continuation indents, multiline string literals), it is possible to want to temporarily change indentLevel by a user-determined value.


#2

The traditional answer to this would of course be:

try {
    fn()
} finally {
    --indentLevel
}

That will always restore the indent level, whether the function call ends in a normal return, a non-local return, an exception…


#3

The problem I noticed is that the developer is not always aware of the possibility of non-local returns. If he didn’t expect non-local returns in a clear mind, he would have crossinlined it anyway.


#4

When using the return statement inside a lambda, you can clearly see syntactically, where it is returning from. A labelless return statement always returns from the innermost function that is declared using the “fun” keyword. It never returns from a lambda.

The only difference is, the labelless return statement is not allowed at all inside a lambda that is not inline.


#5

I think I agree. Non-local return is a special feature and people enabling it should know what they are doing. So crossinline by default seems reasonable. Sadly, it will break backward compatibility, so maybe in distant future.


#6

@fatjoe79 My main point here is that the definer of the inline function is not aware that someone may pass lambdas with non-local returns.
Let’s call “Bob” the person who wrote the inline function, and “Alice” the person who calls the inline function. Bob makes a library that contains an inline fun foo(fn: () -> Unit), but due to not understanding inline functions, or due to carelessness or whatever reason, Bob didn’t realize that the function’s control flow may not fully complete (even assuming no exceptions), e.g. my indentLevel example in the OP. Bob may even have assumed that a loop containing fn will get executed exactly n times (so it can’t be solved with a simple try-finally block).
But these assumptions are not clearly documented, because Bob didn’t expect someone to return non-locally inside foo.
Now Alice calls foo() with a non-local return function. Note that Alice is aware of what non-local return means, and her logic is perfectly valid. She just doesn’t have the source code for the foo function, so she doesn’t know that foo executes something that needs restoration (e.g. setting a stack size to n and decrement every loop). And some bizarre bug occurs and Alice and Bob blame each other. (Mallory is happy)
Note that Alice did nothing wrong here. You may say that Bob has done a few wrong things:

  • If the logic reset is so important, he should have made it internal/private.
    • This boils down to the superman-in-underpants question.
    • Making something public doesn’t mean it can be messed up or must be foolproof and tolerate stupid code.
  • Bob doesn’t know about non-local returns.
    • But you can’t expect every Kotlin developer to recite all the features of the language.
    • A modern language should be designed to make the less common scenario special, not the common scenario special. This is also exactly why we have classes final by default.

The design that return jumps out of all functions looks misleading to people used to Java lambdas too, but that’s out of scope of this discussion. In this thread, I am assuming that people who write return in lambdas always know what they are doing; the point is that people who don’t write that return may forget that other people might do so.


#7

But it’s never safe to assume no exceptions, as there can always be exceptions!

What if something in the lambda throws a NullPointerException or IllegalArgumentException or any other common type? (Or an Errors like InternalError, OutOfMemoryError, VirtualMachineError, ThreadDeath?)

While it wouldn’t make sense for the lambda-calling function to catch any of those (without re-throwing), it should certainly be aware of the possibility, and do any necessary cleanup in a finally block.

…which would also handle the non-local returns!


#8

The problem is that exception safe programming is really-really hard when object state is involved as the exception path should normally maintain the invariants (to be exception safe) which may even involve undoing some things in case of exceptions.

Of course your point is correct, exceptions mean that code that doesn’t handle non-local returns is also not exception safe. There is however something to be said about making crossinline the default instead of the current default. The problem is that this boat has sailed long ago and changing this default now is a breaking change that is very unlikely to happen (it would need to go through a no-default-at-all phase were an explicit choice is required).