Default parameters for getters / setters & friends

I was working on my custom unwrap operator and the limitations of the current syntax (making it a function doesn't allow modification, making it a property doesn't allow to put extra default parameters like file/line, ditto for making it an operator (there's an outstanding bug about default parameters support in operators), making it a subscript makes it look really weird) got me to the following idea: allow default parameters for getters, setters & friends:

var value: Int {
    get(line: Int = #line) {
        print("value getter called at \(line)")
        return 42
    }
    set(line: Int = #line) {
        print("value setter called at \(line), value: \(newValue)")
    }
}

ditto for didSet / willSet, & friends

As it is now, the main parameter name in setter (newValue / oldValue) could be customised, in which case it will be the first item in the parameter list and it will not have the type declaration.

var value: Int {
    get(line: Int = #line) {
        ...
    }
    set(val, line: Int = #line) {
        print("value setter called at \(line), value: \(val)")
    }
}

extra parameters would have to be defaulted:

var value: Int {
    get {...}
    set(val, line) {...} // ๐Ÿ›‘ missing type declaration
    set(val, line: Int) {...} // ๐Ÿ›‘ missing default value
    set(line: Int) {...} // ๐Ÿ›‘ missing default value
}

The use site of getters / setters won't change and there will be no way to pass the parameters explicitly (this will be in line with the upcoming support of default parameters for the operators):

value(line: 1) = 1 // ๐Ÿ›‘
_ = value(line: 1) // ๐Ÿ›‘

2 * (line: 1) 3    // ๐Ÿ›‘
2 * (line: 1) 3    // ๐Ÿ›‘
(*)(2, 3, line: 1) // โœ…
6 Likes

+1 and I would say we need to extend this to operator definitions too. You should be able to have extra operator function parameters, as long as they all have a default value. That way if you're using a custom operator, you can likewise capture the #file and #line of callsite.

(edit: oh yes you mentioned that :innocent: )

2 Likes

Could you please provide a link to the issue/PR/pitch for this upcoming feature?

This feature would also be nice to have for methods that satisfy a protocol requirement. For example, a conformance to ExpressibleByStringLiteral requires to implement the initializer init(stringLiteral:). I wish there was a way to pass further information on to the initializer while at the same time the requirements of ExpressibleByStringLiteral are fulfilled:

struct StringWrapper: ExpressibleByStringLiteral {
    let value: String
    let line: Int

    init(stringLiteral value: String, line: Int = #line) {
        self.value = value
        self.line = line
    }
}

However, while I look forward to have a way to pass information from the call side on to an initializer/a method/a computed property/..., I dislike the idea of misusing default parameters for that.

In the case of operators, there is no way to ever pass a real argument instead of the default. There would be an operator declaration with many arguments which just serve no purpose for the user. Even worse for getters. Why would getters have arguments at all? They are supposed to provide values, not the other way around.

In all the requests for this or similar features, I have only seen #line and #file as examples and both would be my only use case as well. Are there other examples of default arguments or is it really always about these two?

If not, I propose the new macros #callerFile and #callerLine which would be accessible in any kind of callable object. Instead of

func location(path: String = #file, line: Int = #line) -> String {
    path + ":\(line)"
}

we could just write:

func location() -> String {
    #callerPath + ":\(#callerLine)"
}

This would work in initializers, operators, methods, getters, setters, subscripts โ€” you name it โ€” without specifying (potentially confusing) additional parameters.

8 Likes

The way #file and #line are used has always kinda bugged me. Like an increasing number of things in Swift (e.g. actor isolation of non-actor methods) function parameters are being [ab]used to express things unrelated to actual function arguments (at least in the traditional sense). A lot of the time - like #line and #file - they're really just used to instruct the compiler to do something special for the function; to implement optional, additional context passing.

It's true that for #file and #line you do need to express their presence in the function's declaration, but that can still be done without making them actual function parameters, e.g.:

@RequiresCallerSourceLocation // Allows use of #callerPath et al, inside the function.
func location() -> String {
    #callerPath + ":\(#callerLine)"
}
8 Likes

One thing that's nice about having them modeled as actual user-visible parameters is that it enables you to write wrappers around APIs that use #file and #line and still forward along your own caller's context so that the wrapped API reports the 'real' context rather than the useless wrapper context. Of course, you could shoehorn this too into a system which obscures the context's conceptual role as hidden function parameters, but I'm not sure we end up in a better place.

11 Likes

That's true. I had wondered what use-cases there were for manually specifying those arguments.

The downside of course is that if you can manually specify those arguments, it can cause errors (e.g. you mistake a "path" argument for a "path" argument).

1 Like

I don't mind having those as extra parameters, but perhaps we could make them more compact and pass as a single entity instead of passing file, filePath, fileId, line, column all separately:

func location(location = #sourceLocation) -> String {
    location.path + ":\(location.line)"
}

Then when we want to use that:

func wrapperLocation(location = #sourceLocation) -> String {
    location() // โœ…
    location(location: location) // โœ…
    location(location: #sourceLocation) // โœ…
    location(location: SourceLocation(...)) // ๐Ÿ›‘
}

we could either pass #sourceLocation or location being passed from outside. If we have no ability to construct our own location there should be no errors, say passing "file.swift" (that has only 10 lines) with line parameter: 123.

3 Likes

#isolation support for properties would also fall out of such a feature (this is a just-approved feature for upcoming Swift).

2 Likes

Rust has it as an attribute instead (trace_caller) and I find that more subtle, because it affects the ABI, and doesnโ€™t let you mixed locks and inherited location info. (and if it doesnโ€™t affect the ABI, itโ€™s worse for clients that donโ€™t actually plan to save location info. So I actually prefer Swiftโ€™s approach, even if it has its own drawbacks.

3 Likes

And in the other direction, I also really like Rust's approach of putting self within the parameter list syntactically to emphasize that it's 'just' another parameter. Perhaps wouldn't suit Swift quite as well, but I think there's a lot to be said for how the syntax can help with conceptual understanding.

2 Likes

This is indeed a very important detail that wouldn't work with the proposed #callerFile and #callerLine.

However, passing these arguments on to other callees only works for functions (that don't satisfy a protocol requirement), subscripts and initializers. Getters, setters, operators and functions required by protocols wouldn't benefit of this capability as one cannot call them with additional arguments.

Having #callerFile and #callerLine available wouldn't prohibit anyone from using #line and #file as default arguments in certain APIs on top.

1 Like

I've had an idea for a workaround using SE-0252, but unfortunately it does not work.

// error: @dynamicMemberLookup attribute requires 'Magic' to have a 'subscript(dynamicMember:)' method that accepts either 'ExpressibleByStringLiteral' or a key path
@dynamicMemberLookup
struct Magic {
    struct Wrapper {
        var impl: Magic
        var file: StaticString
        var line: Int

        var foo: Int {
            get {
                print("Get foo from \(file):\(line)")
                return impl.fooStorage
            }
            set {
                print("Set foo from \(file):\(line)")
                impl.fooStorage = newValue
            }
        }
    }
    subscript<T>(dynamicMember keyPath: WritableKeyPath<Wrapper, T>, file: StaticString = #file, line: Int = #line) -> T {
        get {
            Wrapper(impl: self, file: file, line: line)[keyPath: keyPath]
        }
        set {
            var wrapper = Wrapper(impl: self, file: file, line: line)
            wrapper[keyPath: keyPath] = newValue
            self = wrapper.impl
        }
    }

    private var fooStorage: Int
}

Though, I think that loosening requirements of SE-0252 might be a smaller change than adding default parameters to properties, which currently cannot have any parameters.

In any case, for every parameter with default value, I would expect it to be possible to pass-non default value as well. Not necessarily with a nice syntax, just possible.

With subscript-based workaround it is possible - you can either use magic[\.foo, file: file, line: line] or construct Wrapper manually.

Operators already can be called as functions - print((+)(1, 2)). I guess, this could be used to pass non-default values to operators.

But I'm quite not sure about syntax for passing non-default values to properties.

Maybe this?

withSourceLocation(file, line) {
	foo.property = 42
}

or a more old-style version:

pushSourceLocation(file, line)
foo.property = 42
popSourceLocation()
1 Like

This is exactly how to use the existing #sourceLocation line control statement.

Yes, just a better version of it, in the current form is leaves a lot to be desired:

struct Foo { var property = 0 }
var foo = Foo()

func foo(_ file: String = #file, line: Int = #line) {
    #sourceLocation(file: file, line: line)
    // ๐Ÿ›‘ Expected filename string literal for #sourceLocation directive
    // ๐Ÿ›‘ Expected starting line number for #sourceLocation directive
    foo.property = 42
    #sourceLocation()
}
func foo() {
    #sourceLocation(file: "file", line: 123) // comment
    // ๐Ÿ›‘ Extra tokens at the end of #sourceLocation directive
    #sourceLocation()
}
func foo() {
    #sourceLocation(file: "file", line: 123)
    #sourceLocation(file: "file", line: 123)
    #sourceLocation()
    #sourceLocation()
    // ๐Ÿ›‘ Parameterless closing #sourceLocation() directive without prior opening #sourceLocation(file:,line:) directive
}

Besides the withSourceLocation {} form feels safer, as with the pushSourceLocation / popSourceLocation form you might simply forget to put the closing popSourceLocation.

3 Likes