Context receivers workaround to allow for type par...
# language-evolution
y
Context receivers workaround to allow for type parameters inside lambdas. (Workaround for KT-51243) I should've shared this ages ago, but I was trying to figure out how to explain the way that it works, and I've given up honestly, so here it goes: It looks like something is broken with context receivers not being visible in lambdas if those contexts are generic type parameters. What's weirder is, after some testing, if you utilise the type parameter in any other part of the lambda signature e.g. a normal parameter, then all the context receivers up to and including that generic type parameter will be visible inside a lambda. This general structure here just has the type of the last generic context as the
TypeWrapper
that is passed to the lambda, effectively making all the receivers before it visible.
Copy code
inline fun <A, B, C, R> withContexts(a: A, b: B, c: C, block: context(A, B, C) (TypeWrapper<C>) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(a, b, c, TypeWrapper.IMPL)
} 
sealed interface TypeWrapper<out A> {
    object IMPL: TypeWrapper<Nothing>
}
(Psst: here's a function to generate those up to an arity of 25):
Copy code
fun generateDeclarations(index: Int): String {
    val alphabet = "BCDEFGHIJKLMNOPQSTUVWXYZ"
    val letters = ("A" + alphabet.take(index)).toList()
    val lastType = letters.last()
    val types = letters.joinToString(", ")
    val receivers = letters.joinToString(", ") { it.lowercase() }
    val parameters = letters.joinToString(", ") { "${it.lowercase()}: $it" }

    @Language("kotlin") val codeTemplate = """
        inline fun <$types, R> withContexts($parameters, block: context($types) (TypeWrapper<$lastType>) -> R): R {
            contract {
                callsInPlace(block, InvocationKind.EXACTLY_ONCE)
            }
            return block($receivers, TypeWrapper.IMPL)
        }
    """.trimIndent()
    return codeTemplate
}
r
I always enjoy reading your issues. 🙂
🙏 1
y
@Richard Gomez thank you a lot, genuinely. I try my best to include the relevant info without writing way too much. I worry sometimes that my paragraphs or "essays" turn into a metaphorical soup of words, so it's reassuring to hear that they're still comprehinsible.
s
Really cool workaround @Youssef Shoaib [MOD]. Insane to see context receivers work with n-arity
with
. https://gist.github.com/nomisRev/6c31a24e6d0dbf0106b10a9048162929
y
Interestingly, because this exists, I'm struggling to see how useful a
with
keyword would actually be (as in the one suggested in the KEEP). The n-arity
with
works in nearly every syntactic place where a
with
keyword would be useful. For instance, if you want to be in a context depending on an if check, a simple
if(condition) with(context1, context2) { }
suffices without extra indentation. Same with
for
,
while
,
when
branches, even functions (you can do
fun foo() = with(c1, c2) { }
). I don't think most users would need to include a context in the middle of a function based on nothing, they're more likely to already use something like
if
or
while
and then enter a context. The only place where this falls short, and where a
with
keyword would be nice, is inside lambdas. It's possible that a user wants to add an extra scope inside a lambda, and needing extra indentation for that would be quite bad. Still, though, I don't see the need for a
with
keyword, especially because its semantics would be a bit unnatural (as in it affects code coming right after it in the same scope. It is sort of like a variable being defined in the middle of a block and used afterward, except that it is implicit, which can make it hard to see a
with
in the middle of like 50 lines of code)
s
I think the only reason to make it a keyword would be to make it n-arity, otherwise, you have to limit the arity and define it in the Kotlin Std. Like you showed, or how I've added it into my snippet. Although with FIR it might be possible to have it defined as a synthetic n-arity method which just generated methods based on your usage, and inlines it during bytecode gen so the method definition doesn't actually exist. So I don't see a real need for it to be a keyword. I always prefer regular functions above keywords, least powerful is always a better choice IMO.I care more about the use-case to be able to do this 😁
1
y
Well, the stdlib could have definitions up to 22-arity for instance. They're trivial to pre-generate anyways. IMO if someone is using more than 22 contexts then something has gone horribly wrong, and even if there's a legitimate use case for 23 contexts, I don't think it would be unreasonable for the user to just accept an extra level of indentation per each set of 22 contexts. Edit: after further thinking, maybe with Arrow and a math-heavy library you might need a lot of
Summable
contexts or other various arithmetic contexts, and so 23 contexts could happen in production.
s
In Arrow there won't ever be a need for this. Generally, these use-cases arise when you have something like serialization where you might have the need to depend on context for big data types (nested data classes) that might require 30 serializers. But I personally don't think is the right use case for context receivers. Context receivers cover some of the use-cases where you might use typeclasses in other languages, but they do not map 1-1. I'm personally liking the approach Kotlin is taking because it focuses on explicitness. For use-cases like serialization, or other similar ones, I think it'll be better to just build a dedicated compiler plugin. And context receivers are great for when you want to do constraint-based programming. Where you want to constrain a function to be used in certain contexts. They'll also be great for really powerful DSLs.