Implicit parameters...?

Good question; this ambiguity exists already in Swift, and causes a compile-time error.

func overloaded(string: String = "") {}
func overloaded(int: Int = 9) {}

overloaded() // 🛑 Ambiguous use of 'testOverload'

But there is a difference. With the existing overloading mechanisms it's genuinely ambiguous which is meant because both are always available.

But with implicit arguments, if there isn't a suitable value available for the implicit version (configuration:), then it's technically unambiguous which was meant as only one method can possibly be called (fileHandle:).

That's why I asked - would the compiler go 'out of its way' to still fail in this case, claiming ambiguity even though technically there is none, or would it just silently use what turns out to be the only viable method? If the latter, how [reliably] will humans follow that logic?

1 Like

I could see it going either way depending on how taxing it is on the compiler. If possible it would be nice if, if there is no available local implicit value, it would simply use the fileHandle version.

As for humans following that logic, perhaps tooling could assist here, somewhat similarly to how it does with macros. I could imagine Xcode having a right-click menu option to show implicit values.

func useContext(_ context: implicit Context) {}

implicit let context = Context()
useContext() // option to show `useContext(*context*)` with ability to link back to implicit value definition

Would it, though? Then you cannot tell from reading the code what its behaviour is.

I think relying on tooling - beyond a static web page or FileMerge or equivalent - during code review is a non-starter. Can you even review Git pull requests in Xcode, for example? (not rhetorical - I know some IDEs try to provide code review support, like IntelliJ) Do you want to? To my knowledge, pretty much everyone either just uses GitHub's website or their company's proprietary equivalent.

2 Likes

IMO, it solves both of these issues. When refactoring areas that use an execution context of some kind (like an executor, or a 'current user' id, or networking client), it's obvious that the value being used is the correct one. If it's set correctly at the top of the hierarchy, it's obvious that it's going to be correct when used down the call stack.

In my experience, implicit values fill a similar need as @TaskLocal values, with the difference being that @TaskLocal requires a default parameter. For example I might have

struct User {
  var id: UUID

  @TaskLocal static var current: User?
}

I can use the User.current throughout the codebase so that I don't have to pass it through explicitly, but it may or may not be present. With implicit values, though, a value must be present or else it won't compile, but I still don't have to manually pass around something that, in my experience, is fairly obvious. With the idea of "the current user", passing it down the call stack is very mechanical and even redundant.

Of course, this has the potential to be abused, but I believe the benefits outweigh the costs there.

2 Likes

Ah, I didn't realize you were talking about code review. :sweat_smile: Honestly, I think using overloads in this kind of situation is probably an anti-pattern; perhaps it shouldn't be allowed at all.

func doTheThing(_ thing: String = "") {}
func doTheThing(assistant: implicit Assistant) {} // 🛑 Function overloads using implicit values are not allowed.

[edit 2023-09-27 11:41]

On another note, it's worth reiterating/reframing something that was mentioned earlier: Swift already has implicit execution contexts passed around through several means, be it the current actor, TaskLocal values, what variables are in scope, whether the current context is async/throwing, etc. This feature could simultaneously increase the power of the programmer while decreasing overhead.

2 Likes

I know it's not the same, but in the past I saw the wish of having #self as an option for default parameter values. If that parameter is constrained to a protocol, you could implicitly access a property from the hosting type (For example for accessing some context).

protocol Contextualized {
  var context: Context { get }
}


class MyObject: Contextualized {
  let context: Context

  func doSomething() {
    let myOtherObject = MyOtherObject()
    // ...
  }
}


class MyOtherObject: Contextualized {
  let context: Context

  init(callSite: some Contextualized = #self) {
    self.context = callSite.context
  }
}

This is just a rough sketch, but with a Macro much of the boilerplate could be eliminated.

But like I said, this is not the same as a dedicated implicit keyword that could be used in more scenarios, but when I read that passing down some context implicitly would be a potential use case, I was reminded about the previous discussions around #self.

2 Likes

These two examples, IMO, are compelling in my view. It's certainly been the case that I've puzzled over functionality that is best spelled (IMO, at least) using infix operators but have some setting or knob like the tolerance example above. There's currently no good way to express this in the language without abandoning the operator altogether.

It seems to me that, oftentimes, the desired use case for such a feature involves a parameter which also has a default value, which one might want to tweak in a uniform way across more than one method call. So in that sense—and certainly in the case of getting rid of our reliance on an underscored feature for executors—the implicit parameter feature would counterintuitively be about offering an easier way to make something explicit.

5 Likes

I guess I am slow, but even after reading all the posts I still don't understand the feature. It looks like you still need to have the value at the call site, so what is the advantage of not having to pass it in? What does this feature enable?

The disadvantages are clearly massive, so presumably there are corresponding advantages that I'm missing, but what are they?

2 Likes

But Swift doesn't have a way to provide [additional] parameters to operators today. Having the ability to provide additional parameters only implicitly seems suboptimal - what if you need to explicitly specify the parameter, such as if you want to override something from a higher scope or just generally on a case-by-case basis? Maybe there's games to be played with temporary local variables, and shadowing, but that seems inelegant.

To be clear, I too would like to see a syntax for providing [additional] parameters to operators, I just think it might be better to either have that (with implicit parameters working the same as anywhere else in the language, if they're then adopted) or not at all.

For anyone curious what we mean, it might look something like:

func foo(x: Double, y: Double, i: inout Int) {
    if x == y (epsilon: 0.1) {
        c += a (rounding: .up)
    }
}

…where those hypothetical enhancements to the existing operators would be defined like e.g.:

func == (a: Double, b: Double, epsilon: Double = 0.0) { … }
func += (lhs: inout Int, rhs: Double, rounding: RoundingMode) { … }

You can see then how implicit parameters could be very handy there, to be able to specify a default epsilon or default rounding mode for a large swathe of code without making a lot of boilerplate. In fact in that case I don't think there's any good alternative to those two - you can't use globals for all the usual reasons (not thread-safe etc), and task or thread locals are error prone and laborious in comparison (e.g. actors have to manually take in such parameters at all their async interfaces).

Overall I'm optimistic towards this implicit parameters idea, iff it has conservative defaults to start with (e.g. only suitably-annotated variables or constants may be considered). I'm still concerned that reading code will become harder because of the hidden parameters.

1 Like

I'm uneasy about this. Obviously the parentheses in x == y (epsilon: 0.1) may refer to "invoke y with ..." or "invoke == with ...", but that's syntax issue, may be solved hundreds of ways. What's bad (IMO, very subjective and judgemental, sorry) here is loosing "binariness" of a binary operator. I didn't think about it really deep, but from the first glance it should be either an implicit pass, like

func foo(x: Double, y: Double, i: inout Int) {
    implicit let epsilon = 0.1
    if x == y {
        implicit let rounding = .up
        c += a
    }
}

or function-style call with all parameters explitit:

func foo(x: Double, y: Double, i: inout Int) {
    if (==)(x, y, epsilon: 0.1) {
        (+=)(&c, a, rounding: .up)
    }
}

A good question. Parameters with defaults are also implicit in their way. This is where the word "implicit" starts to be misleading and somewhat magical woodoo mumbo jumbo. "implicit" in sense I will talk about now just means a value that can be extracted(if not provided explicitly) from a storage, that is associated with the current call stack(not like in Scala). Like "thread local storage" is associated with "thread", like "TaskLocal" is associated with "Task". Implicit storage is associated with "callstack".
For Scala's definition of "implicit" scroll down to @ktoso 's reply.
(Maybe we should stop call it "implicit")

Parameters with defaults are implicit in a sense like "if the value is not provided explicitly, use this expression to evaluate it on the caller site".

So how do we combine them is an open question, but my thoughts would be in this direction:

  • Allow both
  • Specify priorities

For example, I'll use @ktoso 's timeout example.

func f(timeout: Duration = .seconds(42)) {} // explicit ?? default
func g(@implicit timeout: Duration) {} // explicit ?? implicit
func h(@implicit timeout: Duration = .seconds(42)) {} // explicit ?? implicit ?? default

The old good function f declares timeout parameter with default, which instructs compiler to use that if no value was provided explicitly. No brainer.
Function g declares the parameter implicit:

  • If timeout is provided explicitly:
    • Use the provided value
    • Shadow an existing value in the "Implicit storage" if any for all nested calls.
  • Else:
    • Compiler should check timeout: Duration is provided somewhere higher in the call stack.
    • If it isn't emit a compilation error.

Function h declares timeout as implicit and with a default value:

  • If timeout is provided explicitly - same as for g
  • Else:
    • Compiler should check timeout: Duration is provided somewhere higher in the call stack.
    • If it is - use it.
    • Else:
      • Use the default
      • Add the default to the "Implicit storage", so all nested calls use it as well.

About ambiguity.
Well, it's debatable, but I would like if the compiler would be more conservative.
An easy example @JuneBash provided:

It would be nice if compiler complained about ambiguity on declarations. I mean, there's no return value, no other parameters, the generic constraints are the same. There's no syntax in the language to explicitly say "use default". It's clearly will not work on callsites.

In this example you provided:

I would also like the compile complain that this is bad. It's either the first one, or the second one, but not both. But I guess it's kinda okay to postpone the ambiguity check to the callsite as well.
So on the callsite in the checking procedure we can be pessimistic(I'm in favor for this one) or optimistic. The pessimistic approach would be to always behave like if configuration is provided in the "Implicit storage", which means always emit an ambiguity error. The optimistic approach is to check if configuration is actually provided, and if it isn't - it's ok to use log(fileHandle:).
I don't like the optimistic approach, because it's vulnerable to WAT errors. Your code was working for years, but one day you edit some code somewhere higher in the call hierarhcy, introduce an implicit for LogConfiguration and the code stops compiling in the very unrelated to LogConfiguration or the domain you were working on place... Not cool :face_with_diagonal_mouth:

1 Like

Yes, that's my concern too. It's fine if introducing a new overload is source-breaking because it makes some (or all) calls ambiguous, because it's not going to result in bugs shipping to users (though obviously code authors need to be mindful about the trade-offs of a source-breaking change like this, as always).

The alternative, of silently changing which function is actually called when recompiled, seems like a thoroughly bad idea. Even if it's not breaking at the ABI level - which it presumably would not be, as existing callers would have encoded the mangled name of the specific version of the function they were compiled against.

Given that, it seems like implicit parameters cannot be used if there are overloads that make them ambiguous. So furthermore the compiler should at least warn about them at their declaration ("Warning: implicit parameter cannot be used implicitly because of ambiguous overload XYZ…").

1 Like

I'd like to be clear that this is not how implicit values work; We can make up some alternative model but this isn't Scala's implicit values -- which are being discussed for inspiration here so I think it matters to not attribute behavior to them which they do not exhibit..

Implicit values must be in the "implicit search scope". This isn't some arbitrary stack search. It must be accessible in scope -- and not just somewhere on the callstack.

The lookups are performed, more or less in the following order (I may get this slightly wrong, it's been a while):

  • current scope (e.g. block or method)
  • explicit imports of implicit values (Scala can import values, import Clazz.value which is how one can import an execution context etc)
  • other imports
  • associated static scope of involved types (e.g. this way a type provides "default implicit value" if search fails)
  • containing type (e.g. an implicit property inside a struct containing the function in which we're trying to call something that needs an implicit)
  • implicit scope of arguments type, ... (e.g. Timeout may have values defined)
  • outer types if the type was nested
  • global scope (another way for defaults)
  • ...

The lookups can get somewhat expensive in very convoluted cases.

2 Likes

But Scala doesn't require variables & constants to be explicitly opted into this mechanism, right? Presumably if that were done it'd help significantly as the vast majority of variables & constants would not opt-in.

It does.

1 Like

Default implicit values - as opposed to default parameter values for parameters that happen to also be tagged 'implicit' - seems like a big expansion of power (and risk?). Even if they're explicitly opted in, because that can be done by any random library author without anyone else's knowledge.

I get that they might be convenient, but it's a bigger departure from familiar idioms like thread-local storage.

Although, not entirely unlikely AppStorage (in SwiftUI) which are also kinda magical kinda global variables that can have defaults.

So, from privacy and security angles, what stops a malicious library from surreptitiously exfiltrating data from an application? As in, it adds an implicit parameter quietly to an existing API without anyone noticing, e.g.:

func initAdFramework(
    configuration: AdConfiguration,
    appID: String,
    implicit passwords: PasswordStore? = nil) // Totz unused, plz ignore.

If there happens to be 'passwords' of the appropriate type somewhere in scope, they'll now opportunistically gain access to it.

Yes, technically they're in the same address space and could find it in memory anyway, but that's a much harder attack.

I'm sceptical that guidance like "don't mark sensitive things as implicitable" will (a) be followed at all and (b) not rule out a lot of otherwise attractive use-cases for this feature.

One solution would be to disallow implicit passing of parameters across module (or package?) boundaries, but maybe that would be onerously restrictive?

2 Likes

Yep, I was building an example based on the implementation of implicits we use, which is strictly bound to the call hierarchy, and is nothing like lookups in Scala. Sorry if I confused someone. I tried to give an unambiguous meaning to "implicit" in the beginning of the previous message, but let me say it again: what I called "implicit" is "a parameter whose source is call stack associated". You declare an implicit value in a frame, you use it in all nested calls, you leave the frame - the value gone.

Thanks for the clarification, this makes pitch seem a lot more reasonable imo.

I would like to suggest a change to how this is framed, which I think will make it more palatable to a lot of people, and which (imo) solves a lot of the issues that have been raised here.

My suggestion is to reframe this proposal from implicit parameters to "custom function colors."

throws and async are both colors of function. In other words, you can only call them in certain contexts; do scopes, throws functions, async functions, and Tasks.

Implicit parameter functions are similar. But the color is also implicit. However, most of the use cases raised in this thread have an explicit, well-formed concept they're expressing. For example, @ktoso's Task example is so explicit that it's written directly into the Actor protocol so that all Actors can provide it.

Here's an example of how this might look in practice:

scope Contextualized { // this could also be called a `context`, I suppose. `context Contextualized` 
     var context: Context
}

func needsContext() Contextualized -> Something {
     context.something // Contextualized is now effectively a global var
}

class C: Contextualized { // types can have colors, too. Maybe it should look different than a protocol conformance

}

extension Context {
    struct S { // an alternative spelling of the above: unlike in classes or structs, nested types _do_ inherit the member variables in a scope
         
    }
}

Contextualized(context: .init()) {
     needsContext()
     Contextualized(context: .init(something: somethingElse)) { // naturally, you can nest a scope to override stuff
         needsContext()
     }
}

And the Task example becomes

extension Task {
  init(...) Executor? { // colors can be optional 
       if let executor {
             exeuctor?.enqueue(self) // the case where we're in an `actor`, something annotated `@MainActor`, etc
       } else {
            defaultExecutor.enqueue(self) // other uses of `Task`
       }
  }
}

This also works really well in the context of needing to avoid churn in a codebase; you don't need one "context" object anymore, the scope is the context, and you can add as many vars as you want there.

While I don't think the concerns of implicit password are likely to be a serious issue in practice, this also solves the issue of malicious third party libraries; the dependent module must always opt into the dependencies' scopes, not the other way around.

My employer uses a DI framework that's sort of like this, but it's only compile-time verified internally, so dependencies are still constructor injected, creating a lot of boilerplate. A language feature like this could completely eliminate the need for this library.

2 Likes