y
05/24/2023, 4:28 PMAny?.toString()
is overriden by my own custom function.ephemient
05/24/2023, 4:31 PMy
05/24/2023, 4:38 PMephemient
05/24/2023, 4:40 PMplus
isn't an extension, it's a member function, and member functions can't be shadowed by extensionsy
05/24/2023, 4:50 PMtoString()
implementation used by String templates? because it's a compiler builtin?ephemient
05/24/2023, 4:53 PMy
05/24/2023, 4:54 PMYoussef Shoaib [MOD]
05/24/2023, 5:32 PM@HidesMembers
which is meant to do exactly this, but the issue is that it's limited to only forEach
and Throwable.addSuppressed
. I've experimented a little bit with making a compiler plugin that allows it for every function, but it seems that the compiler is a little bit resistant to that.dmitriy.novozhilov
05/25/2023, 7:23 AM@HidesMembers
is a dirty hack which was introduced specially for forEach
function
The problem with that function is performance: ususually resolution stops when first appropriate function was found (and in most cases it happens very early in local or member scope). But if there might be a declaration with @HidesMembers
then compiler should always to lookup all scopes for each function call/property access, because it's impossible to say if there is a really some extensions with @HidesMembers
looking to only member declaration
So to support forEach
we have a simple hacky code like that in resolution algorithm: if (foundCandidate && name != "forEach") stopResolution()
Youssef Shoaib [MOD]
05/25/2023, 7:32 AM@HidesMembers
then? Note that that is currently possible right now because the "constant" hides members name list is just a Java Set, which can be added to. It's also good to note that, from some experimentation, @HidesMembers
deals just fine with context receivers (and so e.g. you can define an extension function with context receivers that only hides members if those context receivers are satisfied) which allows for replacing implementations of methods in very specific contexts (inside of a specific class declaration, or within a DSL).
It feels like there should be some (opt-in, of course) mechanism to make a context receiver take priority in determining an overload, and therefore making an extension win against a member. This mechanism can be approximated through hacking HidesMembers
right now, but could it be supported through more official means?
I know that resolution rules are nearly sacred for Kotlin, and for very good reasons, but the case of contextual extension vs non-contextual member seems to have some room for interpretation, especially when you consider that a contextual extension always wins over a non-contextual extension.
It seems as if the line between member and extension is quite blurry, at least from the eyes of someone consuming a library.dmitriy.novozhilov
05/25/2023, 7:36 AM@HidesMembers
is a hack we are forced to support, not a feature we want to propagate
And it's unwanted to spread its usagedmitriy.novozhilov
05/25/2023, 7:37 AM@HidesMembers
breaks the general intuition how is resolution algorithm worksy
05/25/2023, 7:42 AMnull
. how come?dmitriy.novozhilov
05/25/2023, 7:43 AMnull
has type Nothing?
, and Nothing
is a special type with more-or-less empty scopeYoussef Shoaib [MOD]
05/25/2023, 7:43 AMtoString()
is defined inside Any
, and so it doesn't apply for nulls. Kotlin also has an extension Any?.toString()
which hence gets resolved for null calls, but because it is an extension, it is beaten by the member-extension inside Fooy
05/25/2023, 7:49 AMtoString()
with a more specific one inside Foo
, like Int.toString()
?Youssef Shoaib [MOD]
05/25/2023, 7:51 AMtoString()
is a memberdmitriy.novozhilov
05/25/2023, 7:51 AMy
05/25/2023, 7:51 AMYoussef Shoaib [MOD]
05/25/2023, 8:00 AMobject Foo {
fun Any?.toString(): String = "Foo.$this"
}
class Test(val value: String? = "hello")
fun main() {
println("Local Module:")
printTest(Test())
println("\nDifferent Module:")
printPairFirst("hello" to Unit)
}
// Test is defined in the same module, so smart casting its `value` is safe
fun printTest(test: Test) = with(Foo) {
if(test.value != null) {
val canBeSmartCasted: String = test.value // Uncomment to see that smartcast is safe
println(test.value.toString()) // Prints without Foo.
}
println(test.value.toString()) // Prints with Foo.
}
// Pair isn't defined in the local module, so it can't be smart casted
fun printPairFirst(pair: Pair<String?, Unit>) = with(Foo) {
if(pair.first != null) {
// Error: Smart cast to 'String' is impossible, because 'pair.first' is a public API property declared in different module
//val cannotBeSmartCasted: String = pair.first // Uncomment to see that smartcast isn't safe
println(pair.first.toString()) // Prints with Foo.
}
println(pair.first.toString()) // Prints with Foo.
}
By moving Test
outside of the module, the behavior of the code silently changes. If this code used a real-life function other than toString
, where Test.value
has type T
, and perhaps the author behind the type T
decides to add a member function foo
, while an extension function T?.foo
existed in some other code the consumer of the library had. The consumer of the library was well aware of the addition of member foo
, and they verified that the behavior of their T?.foo
makes sense. Now, if the consumer of the library decides to move Test
out of their local module, all of a sudden their extension T?.foo
is always called, instead of the member foo
.
I admit it is quite a contrived example, but it shows how seemingly trivial changes (like extracting declarations into a new module) can have silent behavior changes. Using the member as the top priority is objectively the right choice in most cases, but perhaps some user-override can help remedy cases were the behavior is well-defined but unintuitive. Alternatively, perhaps some warnings are needed here to warn about weak smart casts, extensions on supertypes where subtype members could shadow, etc.